【油猴脚本】Boss直聘 岗位职责复制

复制代码
// ==UserScript==
// @name         BOSS直聘 · 职位正文自由复制
// @namespace    local.boss-job-copy
// @version      1.0.0
// @description  恢复职位描述的选择、复制和右键菜单,并提供一键复制正文按钮。
// @match        https://www.zhipin.com/*
// @run-at       document-start
// @grant        GM_setClipboard
// @noframes
// ==/UserScript==

(() => {
  'use strict';

  // 已核对的职位描述节点。整个站点匹配用于兼容站内跳转,
  // 只有找到可见正文时才显示按钮。
  const BODY_SELECTOR = '.job-detail-section .job-sec-text';
  const PREFIX = 'tm-boss-job-copy';
  const style = document.createElement('style');
  style.id = `${PREFIX}-style`;
  style.textContent = `
    .job-detail-section, .job-detail-section * {
      -webkit-user-select: text !important;
      user-select: text !important;
    }
    #${PREFIX} {
      position: fixed !important;
      right: 24px !important;
      bottom: 24px !important;
      z-index: 2147483647 !important;
      max-width: min(320px, calc(100vw - 48px));
      font: 14px/1.5 system-ui, sans-serif;
      text-align: right;
    }
    #${PREFIX}[hidden] { display: none !important; }
    #${PREFIX} button {
      appearance: none;
      border: 0;
      border-radius: 8px;
      padding: 10px 16px;
      color: #fff;
      background: #087f83;
      box-shadow: 0 3px 12px #0003;
      font: inherit;
      cursor: pointer;
    }
    #${PREFIX} button:hover { background: #06666a; }
    #${PREFIX} button:focus-visible { outline: 3px solid #50c9cc; outline-offset: 3px; }
    #${PREFIX} button:disabled { cursor: wait; opacity: .7; }
    #${PREFIX} [role="status"]:not(:empty) {
      margin-top: 6px;
      padding: 6px 10px;
      border-radius: 6px;
      background: #fff;
      color: #263238;
      box-shadow: 0 2px 10px #0002;
    }
  `;

  const panel = document.createElement('div');
  panel.id = PREFIX;
  panel.hidden = true;
  const button = document.createElement('button');
  button.type = 'button';
  button.textContent = '复制职位描述';
  const status = document.createElement('div');
  status.setAttribute('role', 'status');
  status.setAttribute('aria-live', 'polite');
  panel.append(button, status);

  function elementOf(node) {
    return node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
  }

  function isEditable(node) {
    const element = elementOf(node);
    return !!element && (element.isContentEditable || !!element.closest('input, textarea, select'));
  }

  function bodyOf(node) {
    return elementOf(node)?.closest(BODY_SELECTOR) || null;
  }

  // 保留浏览器默认行为,只阻止正文事件继续传给网页处理器。
  // 不拦截点击、输入、剪切等其他操作。
  function protectTextEvent(event) {
    if (isEditable(event.target) || isEditable(document.activeElement)) return;
    if (event.type === 'copy') {
      const selection = window.getSelection();
      if (!selection || selection.isCollapsed || isEditable(selection.anchorNode)) return;
      const body = bodyOf(selection.anchorNode);
      if (!body || body !== bodyOf(selection.focusNode)) return;
    } else if (!bodyOf(event.target)) {
      return;
    }
    event.stopImmediatePropagation();
  }

  for (const type of ['copy', 'selectstart', 'contextmenu']) {
    window.addEventListener(type, protectTextEvent, true);
  }

  function visibleBodies() {
    return [...document.querySelectorAll(BODY_SELECTOR)].filter(element => {
      if (!element.isConnected || !element.getClientRects().length) return false;
      const computed = getComputedStyle(element);
      if (computed.visibility === 'hidden' || computed.visibility === 'collapse') return false;
      for (let parent = element; parent; parent = parent.parentElement) {
        if (getComputedStyle(parent).opacity === '0') return false;
      }
      return true;
    });
  }

  function cleanText(text) {
    return text
      // 只规范化康熙部首兼容字,例如"⽑、⼒";保留全角标点。
      .replace(/[\u2F00-\u2FDF]/g, character => character.normalize('NFKC'))
      .replace(/\u00a0/g, ' ')
      .replace(/[\u200b\ufeff]/g, '')
      .replace(/[ \t]+\n/g, '\n')
      .replace(/\n{3,}/g, '\n\n')
      .trim();
  }

  let statusTimer;
  function showStatus(message) {
    clearTimeout(statusTimer);
    status.textContent = message;
    statusTimer = setTimeout(() => { status.textContent = ''; }, 6000);
  }

  button.addEventListener('click', () => {
    // 点击时重新查询,避免切换职位后复制旧正文。
    const texts = visibleBodies().map(element => cleanText(element.innerText)).filter(Boolean);
    const text = [...new Set(texts)].join('\n\n');
    if (!text) {
      showStatus('未找到可见的职位描述,请打开职位详情。');
      return;
    }
    const partial = /登录查看完整内容/.test(text);
    button.disabled = true;
    let settled = false;
    const finish = message => {
      if (settled) return;
      settled = true;
      clearTimeout(watchdog);
      button.disabled = false;
      showStatus(message);
    };
    const watchdog = setTimeout(() => {
      finish('复制未确认,请重试或直接选择正文复制。');
    }, 5000);
    try {
      GM_setClipboard(text, 'text', () => {
        finish(partial
          ? '已复制当前内容;登录后可查看并复制完整描述。'
          : '职位描述已复制,可直接粘贴。');
      });
    } catch (error) {
      finish('复制失败,请检查脚本是否已在油猴中启用。');
      console.error('[BOSS 职位复制]', error);
    }
  });

  function update() {
    if (!document.documentElement) return;
    if (!style.isConnected) (document.head || document.documentElement).append(style);
    if (!document.body) return;
    if (!panel.isConnected) document.body.append(panel);
    const hidden = visibleBodies().length === 0;
    if (panel.hidden !== hidden) panel.hidden = hidden;
  }

  let scheduled = false;
  const observer = new MutationObserver(records => {
    // 忽略自己的状态提示,避免观察器反复触发自身更新。
    if (records.every(record => panel.contains(record.target) || style.contains(record.target))) return;
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      update();
    });
  });
  observer.observe(document, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['class', 'style', 'hidden']
  });
  window.addEventListener('pageshow', update);
  update();
})();
相关推荐
starzy199021 分钟前
深入理解Scala Map:从实现原理到工程实践
开发语言·后端·scala
小lu飞1 小时前
2026小红书小工具怎么制作:纯前端 ZIP 打包、沙箱限制与笔记挂载流程
前端·笔记·ai·ai编程
只睡四小时1 小时前
Three.js Sub-tick:漏判 60.7%→0
开发语言·javascript·性能优化·ecmascript·游戏开发·three.js·碰撞检测
smallcelebration1 小时前
151 从0开始搭建后端项目
前端·firefox
wangruofeng1 小时前
手机和电脑传文件,除了微信还有 LocalSend
前端
码农小河662 小时前
基于SSM+JSP的高校专业信息管理系统【附源码+数据库】
java·开发语言·数据库
excel2 小时前
基于 GPT 的自动发布文章接口方案:从一句话或一张图片生成并发布内容
前端·javascript·后端
东鹏特饮4 小时前
系统运维(传统/通用运维)面试题
开发语言·php
其实防守也摸鱼4 小时前
个人学习 Pwn 思路:从基础到漏洞利用的进阶路径
大数据·前端·人工智能·chrome·算法·安全·网络安全