// ==UserScript==
// @name 求职助手
// @namespace job_seeking_helper
// @author Gloduck
// @license MIT
// @version 1.0
// @description 为相关的求职平台(BOSS直聘、拉勾、智联招聘、猎聘)添加一些实用的小功能,如自定义薪资范围、过滤黑名单公司等。
// @match https://www.zhipin.com/*
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function () {
'use strict';
// 策略枚举
const JobPlatform = {
BOSS_ZHIPIN: Symbol('Boss'), LAGOU: Symbol('Lagou'), UNKNOWN: Symbol('未知网站')
};
const JobPageType = {
SEARCH: Symbol('Search'), RECOMMEND: Symbol('Recommend')
}
const JobFilterType = {
BLACKLIST: Symbol('Blacklist'), VIEWED: Symbol('Viewed'), MISMATCH_CONDITION: Symbol('MismatchCondition'),
}
let curPageHash = null;
// 默认设置
const defaults = {
blacklist: [],
minSalary: 0,
maxSalary: Infinity,
maxDailyHours: 8,
maxMonthlyDays: 22,
maxWeeklyCount: 4,
viewedAction: 'mark',
blacklistAction: 'hide',
conditionAction: 'hide'
};
// 加载用户设置
const settings = {
blacklist: GM_getValue('blacklist', defaults.blacklist),
minSalary: GM_getValue('minSalary', defaults.minSalary),
maxSalary: GM_getValue('maxSalary', defaults.maxSalary),
maxDailyHours: GM_getValue('maxDailyHours', defaults.maxDailyHours),
maxMonthlyDays: GM_getValue('maxMonthlyDays', defaults.maxMonthlyDays),
maxWeeklyCount: GM_getValue('maxWeeklyCount', defaults.maxWeeklyCount),
viewedAction: GM_getValue('viewedAction', defaults.viewedAction),
blacklistAction: GM_getValue('blacklistAction', defaults.blacklistAction),
conditionAction: GM_getValue('conditionAction', defaults.conditionAction)
};
// 注册(不可用)设置菜单
GM_registerMenuCommand('职位过滤设置', showSettings);
function showSettings() {
const dialog = document.createElement('div');
dialog.style = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #ffffff;
padding: 25px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
z-index: 9999;
min-width: 380px;
font-family: 'Segoe UI', sans-serif;
`;
dialog.innerHTML = `
<h2 style="color: #2c3e50; margin: 0 0 20px; font-size: 1.4em;">职位过滤设置</h2>
<!-- 基础设置 -->
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">黑名单关键词(逗号分隔)</label>
<input type="text" id="blacklist"
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"
value="${settings.blacklist.join(',')}">
</div>
<!-- 薪资设置 -->
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">薪资范围(元/月)</label>
<div style="display: flex; gap: 10px;">
<input type="number" id="minSalary" placeholder="最低"
style="flex: 1; padding: 8px;" value="${settings.minSalary}">
<span style="align-self: center;">-</span>
<input type="number" id="maxSalary" placeholder="最高"
style="flex: 1; padding: 8px;" value="${settings.maxSalary}">
</div>
</div>
<div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
<h3 style="color: #2c3e50; margin: 0 0 15px; font-size: 1.1em;">工作时间限制</h3>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px;">
<div>
<label style="display: block; margin-bottom: 6px;">日工作小时</label>
<input type="number" id="maxDailyHours"
min="1" max="24" step="0.5"
style="width: 100%; padding: 8px;"
value="${settings.maxDailyHours}">
</div>
<div>
<label style="display: block; margin-bottom: 6px;">月工作天数</label>
<input type="number" id="maxMonthlyDays"
min="1" max="31" step="1"
style="width: 100%; padding: 8px;"
value="${settings.maxMonthlyDays}">
</div>
<div>
<label style="display: block; margin-bottom: 6px;">月工作周数</label>
<input type="number" id="maxWeeklyCount"
min="1" max="6" step="0.5"
style="width: 100%; padding: 8px;"
value="${settings.maxWeeklyCount}">
</div>
</div>
</div>
<!-- 整合后的处理方式设置 -->
<div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
<h3 style="color: #2c3e50; margin: 0 0 15px; font-size: 1.1em;">处理方式设置</h3>
<div style="margin-bottom: 12px;">
<label style="display: block; margin-bottom: 6px;">黑名单职位:</label>
${createRadioGroup('blacklistAction', ['delete', 'hide', 'mark'], settings.blacklistAction)}
</div>
<div style="margin-bottom: 12px;">
<label style="display: block; margin-bottom: 6px;">已查看职位:</label>
${createRadioGroup('viewedAction', ['delete', 'hide', 'mark'], settings.viewedAction)}
</div>
<div>
<label style="display: block; margin-bottom: 6px;">不符合条件职位:</label>
${createRadioGroup('conditionAction', ['delete', 'hide', 'mark'], settings.conditionAction)}
</div>
</div>
<div style="margin-top: 25px; padding-top: 20px; border-top: 1px solid #eee;">
<button id="clearCacheBtn"
style="
background: #95a5a6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.2s;
">
清理已查看职位
</button>
<span style="color: #7f8c8d; font-size: 0.9em; margin-left: 10px;">
将重置当前平台所有已经查看的职位
</span>
</div>
<!-- 操作按钮 -->
<div style="display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid #eee; padding-top: 20px;">
<button id="saveBtn" class="dialog-btn primary">保存</button>
<button id="cancelBtn" class="dialog-btn secondary">取消</button>
</div>
`;
// 添加样式
const style = document.createElement('style');
style.textContent = `
.dialog-btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
}
.primary {
background: #3498db;
color: white;
}
.secondary {
background: #f0f0f0;
color: #666;
}
.radio-group {
display: flex;
gap: 15px;
margin-top: 5px;
}
.radio-item label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
`;
dialog.appendChild(style);
document.body.appendChild(dialog);
// 事件绑定
dialog.querySelector('#saveBtn').addEventListener('click', saveSettings);
dialog.querySelector('#cancelBtn').addEventListener('click', () => dialog.remove());
dialog.querySelector('#clearCacheBtn').addEventListener('click', () => {
if (confirm('确定要清理已查看职位吗?\n这将重置当前平台所有已经查看的职位!')) {
clearViewedJob(choosePlatForm());
alert('清理完成!');
location.reload();
}
});
}
// 生成单选按钮组
function createRadioGroup(name, options, selected) {
return `
<div class="radio-group">
${options.map(opt => `
<div class="radio-item">
<label>
<input type="radio" name="${name}" value="${opt}" ${opt === selected ? 'checked' : ''}>
${getActionLabel(opt)}
</label>
</div>
`).join('')}
</div>
`;
}
// 获取操作标签
function getActionLabel(action) {
const labels = {
delete: '删除', hide: '屏蔽', mark: '标识'
};
return labels[action] || action;
}
function saveSettings() {
settings.blacklist = document.querySelector('#blacklist').value
.split(',')
.map(s => s.trim().toLowerCase())
.filter(Boolean);
settings.minSalary = parseInt(document.querySelector('#minSalary').value) || 0;
settings.maxSalary = parseInt(document.querySelector('#maxSalary').value) || Infinity;
settings.viewedAction = document.querySelector('input[name="viewedAction"]:checked').value;
settings.blacklistAction = document.querySelector('input[name="blacklistAction"]:checked').value;
settings.conditionAction = document.querySelector('input[name="conditionAction"]:checked').value;
settings.maxDailyHours = parseFloat(document.querySelector('#maxDailyHours').value) || 0;
settings.maxMonthlyDays = parseInt(document.querySelector('#maxMonthlyDays').value) || 0;
settings.maxWeeklyCount = parseFloat(document.querySelector('#maxWeeklyCount').value) || 0;
// 保存设置
GM_setValue('blacklist', settings.blacklist);
GM_setValue('minSalary', settings.minSalary);
GM_setValue('maxSalary', settings.maxSalary);
GM_setValue('viewedAction', settings.viewedAction);
GM_setValue('blacklistAction', settings.blacklistAction);
GM_setValue('conditionAction', settings.conditionAction);
GM_setValue('maxDailyHours', settings.maxDailyHours);
GM_setValue('maxMonthlyDays', settings.maxMonthlyDays);
GM_setValue('maxWeeklyCount', settings.maxWeeklyCount);
document.querySelector('div').remove();
alert('设置已保存!');
location.reload();
}
/**
*
* @return {symbol}
*/
function choosePlatForm() {
const href = window.location.href;
if (href.includes('zhipin.com')) {
return JobPlatform.BOSS_ZHIPIN;
} else {
return JobPlatform.UNKNOWN;
}
}
/**
*
* @returns {PlatFormStrategy}
*/
function getStrategy() {
switch (choosePlatForm()) {
case JobPlatform.BOSS_ZHIPIN:
return new BossStrategy();
default:
throw new Error('Unsupported platform')
}
}
/**
*
* @param {String} salaryRange
*/
function parseSlaryToMontyly(salaryRange) {
const regex = /(\d+(\.\d+)?)\s*-\s*(\d+(\.\d+)?)/;
const match = salaryRange.match(regex);
if (!match) {
throw new Error('Invalid salary range format');
}
// 提取最小值和最大值
let minSalary = parseFloat(match[1]);
let maxSalary = parseFloat(match[3]);
if (salaryRange.includes('K') || salaryRange.includes('k')) {
minSalary = minSalary * 1000;
maxSalary = maxSalary * 1000;
}
if (salaryRange.includes('周')) {
minSalary = minSalary * settings.maxWeeklyCount;
maxSalary = maxSalary * settings.maxWeeklyCount;
} else if (salaryRange.includes('天')) {
minSalary = minSalary * settings.maxMonthlyDays;
maxSalary = maxSalary * settings.maxMonthlyDays;
} else if (salaryRange.includes('时')) {
minSalary = minSalary * settings.maxMonthlyDays * settings.maxDailyHours;
maxSalary = maxSalary * settings.maxMonthlyDays * settings.maxDailyHours;
}
return {
min: minSalary, max: maxSalary,
}
}
/**
*
* @param {Symbol} jobPlatform
*/
function clearViewedJob(jobPlatform) {
let jobViewKey = getJobViewKey(jobPlatform);
GM_setValue(jobViewKey, []);
}
/**
*
* @param {Symbol} jobPlatform
* @param {String} uniqueKey
*/
function setJobViewed(jobPlatform, uniqueKey) {
let jobViewKey = getJobViewKey(jobPlatform);
const jobViewedSet = getJobViewedSet(jobPlatform);
if (jobViewedSet.has(uniqueKey)) {
return;
}
jobViewedSet.add(uniqueKey);
GM_setValue(jobViewKey, [...jobViewedSet]);
}
/**
*
* @param {Symbol} jobPlatform
* @return {Set<String>}
*/
function getJobViewedSet(jobPlatform) {
let jobViewKey = getJobViewKey(jobPlatform);
return new Set(GM_getValue(jobViewKey, []));
}
/**
*
* @param {Symbol} jobPlatform
* @return {string}
*/
function getJobViewKey(jobPlatform) {
return jobPlatform.description + "ViewHistory";
}
class PlatFormStrategy {
/**
* @returns {JobPageType}
*/
fetchJobPageType() {
throw new Error('Method not implemented')
}
/**
* @param {JobPageType} jobPageType
* @returns {NodeListOf<Element>}
*/
fetchJobElements(jobPageType) {
throw new Error('Method not implemented')
}
/**
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @returns {String|null}
*/
fetchJobUniqueKey(jobElement, jobPageType) {
throw new Error('Method not implemented')
}
/**
*
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @returns {{min: number, max: number}}
*/
parseSalary(jobElement, jobPageType) {
throw new Error('Method not implemented')
}
/**
*
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @returns {String}
*/
parseJobName(jobElement, jobPageType) {
throw new Error('Method not implemented')
}
/**
*
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @returns {String}
*/
parseCompanyName(jobElement, jobPageType) {
throw new Error('Method not implemented')
}
/**
*
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @param {JobFilterType[]} jobFilterTypes
* @returns {void}
*/
markCurJobElement(jobElement, jobPageType, jobFilterTypes) {
throw new Error('Method not implemented')
}
/**
*
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @param {JobFilterType[]} jobFilterTypes
* @returns {void}
*/
blockCurJobElement(jobElement, jobPageType, jobFilterTypes) {
throw new Error('Method not implemented')
}
/**
*
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @returns {void}
*/
removeCurJobElement(jobElement, jobPageType) {
throw new Error('Method not implemented')
}
/**
*
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @param {Function} eventCallback
* @returns {void}
*/
addViewedCallback(jobElement, jobPageType, eventCallback) {
throw new Error('Method not implemented')
}
/**
*
* @param {Element} element
*/
addDeleteLine(element) {
const delElement = document.createElement('del');
while (element.firstChild) {
delElement.appendChild(element.firstChild);
}
element.appendChild(delElement);
}
/**
* @param {JobFilterType} jobFilterType
* @returns {String}
*/
convertFilterTypeToMessage(jobFilterType) {
if (jobFilterType === JobFilterType.BLACKLIST) {
return '黑名单';
} else if (jobFilterType === JobFilterType.VIEWED) {
return '已查看';
} else if (jobFilterType === JobFilterType.MISMATCH_CONDITION) {
return '条件不符';
} else {
return '未知';
}
}
}
class BossStrategy extends PlatFormStrategy {
fetchJobPageType() {
if (document.querySelector('.search-job-result') != null) {
return JobPageType.SEARCH;
}
return null;
}
fetchJobElements(jobPageType) {
if (jobPageType === JobPageType.SEARCH) {
return document.querySelectorAll('ul.job-list-box > li.job-card-wrapper');
} else {
throw new Error('Not a job element')
}
}
fetchJobUniqueKey(jobElement, jobPageType) {
if (jobPageType === JobPageType.SEARCH) {
const element = jobElement.querySelector('.job-card-left');
if (element == null) {
return null;
}
const url = element.href;
if (url == null) {
return null;
}
return url.split('/job_detail/')[1].split('.html')[0];
} else {
throw new Error('Not a job element')
}
}
parseSalary(jobElement, jobPageType) {
if (jobPageType === JobPageType.SEARCH) {
const salary = jobElement.querySelector('.salary').textContent;
return parseSlaryToMontyly(salary);
} else {
throw new Error('Not a job element')
}
}
parseCompanyName(jobElement, jobPageType) {
if (jobPageType === JobPageType.SEARCH) {
return jobElement.querySelector('.company-name > a').textContent
} else {
throw new Error('Not a job element')
}
}
parseJobName(jobElement, jobPageType) {
if (jobPageType === JobPageType.SEARCH) {
return jobElement.querySelector('.job-name').textContent
} else {
throw new Error('Not a job element')
}
}
addViewedCallback(jobElement, jobPageType, eventCallback) {
jobElement.addEventListener('click', eventCallback, true);
}
markCurJobElement(jobElement, jobPageType, jobFilterTypes) {
if (jobPageType === JobPageType.SEARCH) {
const titleElement = jobElement.querySelector('.job-title');
let markSpan = titleElement.querySelector('.mark');
if (markSpan === null) {
markSpan = document.createElement('span');
markSpan.classList.add('mark');
markSpan.style.color = 'red';
titleElement.insertBefore(markSpan, titleElement.firstChild);
}
markSpan.textContent = '(' + jobFilterTypes.map(jobFilterType => this.convertFilterTypeToMessage(jobFilterType)).join('|') + ')';
this.changeJobElementColor(jobElement, jobPageType);
} else {
throw new Error('Not a job element')
}
}
blockCurJobElement(jobElement, jobPageType, jobFilterTypes) {
const message = jobFilterTypes.map(jobFilterType => this.convertFilterTypeToMessage(jobFilterType)).join('|');
if (jobPageType === JobPageType.SEARCH) {
const cardBody = jobElement.querySelector('.job-card-body');
cardBody.innerHTML = `
<div class="job-card-left"></div>
<div class="tip" style="color: dimgray; font-weight: bold; font-size: large; padding-top: 20px">已屏蔽</div>
<div class="job-card-right"></div>
`;
const cardFooter = jobElement.querySelector('.job-card-footer');
cardFooter.innerHTML = `
<div class="info-desc">${message}</div>
`;
this.changeJobElementColor(jobElement, jobPageType);
} else {
throw new Error('Not a job element')
}
}
removeCurJobElement(jobElement, jobPageType) {
jobElement.parentElement.removeChild(jobElement);
// jobElement.style.display = 'none';
}
/**
*
* @param {Element} jobElement
* @param {JobPageType} jobPageType
*/
changeJobElementColor(jobElement, jobPageType) {
if (jobPageType === JobPageType.SEARCH) {
jobElement.style.backgroundColor = '#e1e1e1';
} else {
throw new Error('Not a job element')
}
}
}
setInterval(() => {
const strategy = getStrategy();
if (strategy == null) {
return;
}
let jobPageType = strategy.fetchJobPageType();
if (jobPageType == null) {
return;
}
const pageHash = getPageHash();
if (pageHash !== curPageHash) {
const jobPlatform = choosePlatForm();
const viewedJobIds = getJobViewedSet(jobPlatform);
const elements = strategy.fetchJobElements(jobPageType);
for (let i = 0; i < elements.length; i++) {
const job = elements[i];
const id = strategy.fetchJobUniqueKey(job, jobPageType);
if (id == null) {
continue;
}
initEventHandler(jobPlatform, strategy, jobPageType, job);
const salary = strategy.parseSalary(job, jobPageType);
const companyName = strategy.parseCompanyName(job, jobPageType);
let jobName = strategy.parseJobName(job, jobPageType);
console.log(`Id:${id},公司:${companyName},岗位:${jobName},月薪: ${salary.min} - ${salary.max}`);
const jobFilterType = filterElement(strategy, job, jobPageType, viewedJobIds);
handleElement(strategy, job, jobPageType, jobFilterType);
}
// 元素可能变动了,重新计算Hash
curPageHash = getPageHash();
}
}, 1000)
/**
* @param {PlatFormStrategy} strategy
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @param {Set<String>} viewedJobs
* @returns {JobFilterType[]}
*/
function filterElement(strategy, jobElement, jobPageType, viewedJobs) {
const filterTypes = [];
const companyName = strategy.parseCompanyName(jobElement, jobPageType).toLowerCase();
for (let i = 0; i < settings.blacklist.length; i++) {
if (companyName.includes(settings.blacklist[i])) {
filterTypes.push(JobFilterType.BLACKLIST);
break
}
}
const jobId = strategy.fetchJobUniqueKey(jobElement, jobPageType);
if (viewedJobs.has(jobId)) {
filterTypes.push(JobFilterType.VIEWED);
}
const companySalary = strategy.parseSalary(jobElement, jobPageType);
if (companySalary.min < settings.minSalary || companySalary.max > settings.maxSalary) {
filterTypes.push(JobFilterType.MISMATCH_CONDITION);
}
return filterTypes;
}
/**
*
* @param {PlatFormStrategy} strategy
* @param {Element} jobElement
* @param {JobPageType} jobPageType
* @param {JobFilterType[]} jobFilterTypes
*/
function handleElement(strategy, jobElement, jobPageType, jobFilterTypes) {
if (jobFilterTypes.length === 0) {
return;
}
let filter = jobFilterTypes.map(filterType => {
if (filterType === JobFilterType.BLACKLIST) {
return settings.blacklistAction;
} else if (filterType === JobFilterType.VIEWED) {
return settings.viewedAction;
} else if (filterType === JobFilterType.MISMATCH_CONDITION) {
return settings.conditionAction;
} else {
return null;
}
}).filter(action => action != null && typeof action === 'string');
if (filter.includes('delete')) {
strategy.removeCurJobElement(jobElement, jobPageType);
} else if (filter.includes('hide')) {
strategy.blockCurJobElement(jobElement, jobPageType, jobFilterTypes);
} else if (filter.includes('mark')) {
strategy.markCurJobElement(jobElement, jobPageType, jobFilterTypes);
}
}
function getPageHash() {
const strategy = getStrategy();
let jobPageType = strategy.fetchJobPageType();
const elements = strategy.fetchJobElements(jobPageType);
let keys = '';
for (let i = 0; i < elements.length; i++) {
const id = strategy.fetchJobUniqueKey(elements[i], jobPageType);
if (id === null) {
continue;
}
keys += id;
}
return hashCode(keys);
}
/**
*@param {Symbol} jobPlatform
* @param {PlatFormStrategy} strategy
* @param {JobPageType} jobPageType
* @param {Element} element
*/
function initEventHandler(jobPlatform, strategy, jobPageType, element) {
const callBack = () => {
const id = strategy.fetchJobUniqueKey(element, jobPageType);
setJobViewed(jobPlatform, id);
// 重置PageHash,来刷新
curPageHash = null;
};
strategy.addViewedCallback(element, jobPageType, callBack);
}
/**
*
* @param {String} str
* @returns {number}
*/
function hashCode(str) {
let hash = 0;
if (str.length === 0) return hash;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
})();