santifer/career-ops · error

feishu-jobs: careers_url must use HTTPS on…

Error message

feishu-jobs: careers_url must use HTTPS on jobs.bytedance.com or a *.jobs.feishu.cn tenant

What it means

feishu-jobs provider fetch() resolves the tenant origin from entry.careers_url via resolveFeishuOrigin(), which only accepts HTTPS URLs on jobs.bytedance.com or a *.jobs.feishu.cn subdomain. If careers_url is missing, not a string, unparseable, non-HTTPS, or on any other host, fetch throws this error because it cannot build the /api/v1/search/job/posts endpoint. This is the provider's SSRF/trust boundary, shared with detect().

Solutions

  1. Set careers_url to the exact origin: https://jobs.bytedance.com for ByteDance, or the tenant's https://<subdomain>.jobs.feishu.cn for third-party tenants (e.g. https://vrfi1sk8a0.jobs.feishu.cn for MiniMax).
  2. Ensure the value is a full URL with https:// scheme — resolveFeishuOrigin returns null for bare hostnames and silently skips them.
  3. Verify the hostname: it must be exactly jobs.bytedance.com or end with .jobs.feishu.cn; a company's own domain that fronts a Feishu board will not pass — find the underlying feishu.cn tenant origin (check where the careers site's API calls go).
  4. If the entry is not actually a Feishu Jobs board, remove or correct the provider: feishu-jobs pinning so detect() picks the right provider.

Example fix

// before
- name: MiniMax
  careers_url: https://www.minimaxi.com/careers
// after
- name: MiniMax
  careers_url: https://vrfi1sk8a0.jobs.feishu.cn
Defensive patterns

Strategy: validation

Validate before calling

function isFeishuOrigin(value) {
  if (typeof value !== 'string') return false;
  try {
    const u = new URL(value);
    return u.protocol === 'https:' &&
      (u.hostname === 'jobs.bytedance.com' || u.hostname.endsWith('.jobs.feishu.cn'));
  } catch { return false; }
}

Type guard

const hasValidFeishuCareersUrl = (entry) => isFeishuOrigin(entry?.careers_url);

Try / catch

try {
  const jobs = await feishuProvider.fetch(entry, ctx);
} catch (err) {
  if (String(err.message).includes('must use HTTPS on jobs.bytedance.com')) {
    console.error(`Fix ${entry.name}.careers_url: needs https://jobs.bytedance.com or a *.jobs.feishu.cn origin`);
    return [];
  }
  throw err;
}

Prevention

When it happens

Trigger: fetch(entry) where entry.careers_url is undefined, an empty or malformed string ('jobs.bytedance.com' without scheme), uses http:, or points at a host outside the allowlist (e.g. a company's own domain that merely embeds a Feishu widget, or www.bytedance.com). Also fires when an explicit provider: feishu-jobs selection bypasses detect() on an incompatible entry.

Common situations: Configuring a company that uses Feishu Jobs internally but hosts careers on its own domain — the real tenant URL is its *.jobs.feishu.cn subdomain which must be found and used; typos or missing https:// in portals.yml; copy-pasting a marketing URL instead of the careers-site origin.

Understand the failure class

Background: "Invalid URL" / "URL cannot be empty": fix the malformed or missing URL behind request-construction failures — this error's family across 50 libraries.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/854ab49aeb3927f1. Report an issue: GitHub.

Appendix: source

Thrown at providers/feishu-jobs.mjs:133

      postedAt: Number.isFinite(p.publish_time) ? p.publish_time : undefined,
    });
  }
  return { jobs, total };
}

/** @type {Provider} */
export default {
  id: 'feishu-jobs',

  detect(entry) {
    const origin = resolveFeishuOrigin(entry.careers_url);
    return origin ? { url: origin } : null;
  },

  async fetch(entry, ctx) {
    const origin = resolveFeishuOrigin(entry.careers_url);
    if (!origin) {
      throw new Error('feishu-jobs: careers_url must use HTTPS on jobs.bytedance.com or a *.jobs.feishu.cn tenant');
    }
    const api = `${origin}/api/v1/search/job/posts`;

    const keywords = Array.isArray(entry.keywords) && entry.keywords.length
      ? entry.keywords
      : DEFAULT_KEYWORDS;
    const entryLimit = Number(entry.max_pages);
    const probeLimit = Number(ctx?.maxPages);
    const entryMaxPages = Number.isSafeInteger(entryLimit) && entryLimit > 0
      ? entryLimit
      : DEFAULT_MAX_PAGES;
    const probeMaxPages = Number.isSafeInteger(probeLimit) && probeLimit > 0
      ? probeLimit
      : Infinity;
    const maxPages = Math.min(entryMaxPages, probeMaxPages);

    /** @type {Map<string, import('./_types.js').Job>} */
    const seen = new Map();

View on GitHub (pinned to aac998c7ed)