Auto-deploy: 2026-08-05 17:41:16
This commit is contained in:
+37
-15
@@ -66,11 +66,12 @@
|
||||
'.topcard__title',
|
||||
]);
|
||||
|
||||
// LinkedIn renders screen-reader-only headings throughout its chrome
|
||||
// ("0 notifications", "1 new message"). They are real <h2> elements with
|
||||
// real text, so any document-wide heading scan will happily return one as
|
||||
// the job title. Require the element to be visibly rendered and to not look
|
||||
// like navigation furniture.
|
||||
// LinkedIn's job panel is full of text that is neither navigation nor the
|
||||
// job title: screen-reader-only headings ("0 notifications"), and product
|
||||
// furniture inside the detail column itself ("Are these results helpful?",
|
||||
// "Your profile and resume match...", "Get personalized tips..."). A
|
||||
// heuristic DOM scan cannot reliably tell those from a job title, so the
|
||||
// scan is now the LAST resort rather than the second.
|
||||
function isVisible(el) {
|
||||
if (!el || !el.offsetParent) return false;
|
||||
if (el.closest('[aria-hidden="true"]')) return false;
|
||||
@@ -80,20 +81,48 @@
|
||||
return !/visually-hidden|sr-only|a11y-text/.test(cls);
|
||||
}
|
||||
|
||||
const NAV_NOISE = /notification|message|invitation|my network|feed update|search|premium|^\d+\s|jobs|people|similar|featured|recommended/i;
|
||||
const NAV_NOISE = new RegExp([
|
||||
// chrome / navigation
|
||||
'notification', 'message', 'invitation', 'my network', 'feed update',
|
||||
'premium', 'search', 'similar', 'featured', 'recommended',
|
||||
// in-panel product furniture — the class of string that broke this before
|
||||
'helpful', 'results', 'feedback', 'your profile', 'match', 'tips',
|
||||
'people you', 'about the job', 'sign in', 'join now', 'people also',
|
||||
'jobs based on', 'reach out',
|
||||
// counters
|
||||
'^\\d+\\s'
|
||||
].join('|'), 'i');
|
||||
|
||||
function plausibleTitle(text) {
|
||||
return text.length > 4 && text.length < 100 && !NAV_NOISE.test(text);
|
||||
}
|
||||
|
||||
// Fallback: a heading inside the job detail panel only — never page-wide.
|
||||
// Preferred fallback: the browser tab title. LinkedIn sets it to
|
||||
// "(N) Job Title | Company | LinkedIn" on every job page, it is not part of
|
||||
// the React DOM that keeps getting restructured, and it cannot pick up a
|
||||
// stray card heading. This is far more reliable than guessing at headings,
|
||||
// so it runs before the DOM scan, not after it.
|
||||
if (!data.jobTitle) {
|
||||
// The "(N)" prefix is LinkedIn's unread counter and can read "(99+)".
|
||||
const m = document.title.match(/^(?:\(\d+\+?\)\s*)?(.+?)\s*[|·]/);
|
||||
if (m) {
|
||||
const candidate = m[1].trim();
|
||||
if (plausibleTitle(candidate) && !/^linkedin$/i.test(candidate)) {
|
||||
data.jobTitle = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: a heading inside the job detail panel only — never page-wide.
|
||||
if (!data.jobTitle) {
|
||||
const jobPanel = document.querySelector(
|
||||
'.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout, ' +
|
||||
'.jobs-details, [class*="jobs-unified-top-card"], main'
|
||||
);
|
||||
if (jobPanel) {
|
||||
for (const h of jobPanel.querySelectorAll('h1, h2')) {
|
||||
// h1 first: the job title is a level-1 heading, while the cards that
|
||||
// polluted this before ("Are these results helpful?") are h2/h3.
|
||||
for (const h of [...jobPanel.querySelectorAll('h1'), ...jobPanel.querySelectorAll('h2')]) {
|
||||
const text = h.textContent.trim();
|
||||
if (isVisible(h) && plausibleTitle(text)) {
|
||||
data.jobTitle = text;
|
||||
@@ -103,13 +132,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: the browser tab title, which LinkedIn formats as
|
||||
// "(N) Job Title | Company | LinkedIn". Still better than a nav label.
|
||||
if (!data.jobTitle) {
|
||||
const m = document.title.match(/^(?:\(\d+\)\s*)?(.+?)\s*\|/);
|
||||
if (m && plausibleTitle(m[1].trim())) data.jobTitle = m[1].trim();
|
||||
}
|
||||
|
||||
// ── Company Name
|
||||
data.company = trySelectors([
|
||||
'.job-details-jobs-unified-top-card__company-name a',
|
||||
|
||||
+28
-3
@@ -520,9 +520,34 @@ PROMPT;
|
||||
if ($pageCount <= $TARGET_PAGES) break;
|
||||
}
|
||||
|
||||
// ATS parsers key on the filename too — "Name - Role.pdf" reads cleanly.
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9\-_ ]/', '', $id['fullName']);
|
||||
$safeTitle = preg_replace('/[^a-zA-Z0-9\-_ ]/', '', $jobTitle ?: 'CV');
|
||||
// ATS parsers key on the filename too — "Name - Role.pdf" reads cleanly,
|
||||
// and a recruiter sees it before opening anything.
|
||||
//
|
||||
// Scraping a job title out of LinkedIn's DOM is fragile, and a bad
|
||||
// scrape has shipped filenames like "Hamza Ayed - 0 notifications.pdf"
|
||||
// and "Hamza Ayed - Are these results helpful.pdf". The client is fixed,
|
||||
// but the server must not depend on the client being right: reject
|
||||
// anything that does not look like a job title and fall back to the
|
||||
// profile's own positioning instead.
|
||||
$rawTitle = trim($jobTitle);
|
||||
$looksLikeUiText = preg_match(
|
||||
'/helpful|result|feedback|notification|message|your profile|match|tips|about the job|^\d/i',
|
||||
$rawTitle
|
||||
);
|
||||
// Real job titles carry a role noun. Requiring one rejects stray UI
|
||||
// strings without hardcoding a list of every possible title.
|
||||
$hasRoleNoun = preg_match(
|
||||
'/engineer|developer|architect|lead|manager|consultant|specialist|analyst|designer|' .
|
||||
'programmer|scientist|director|head of|principal|staff|senior|founding|cto|technologist/i',
|
||||
$rawTitle
|
||||
);
|
||||
|
||||
if ($rawTitle === '' || $looksLikeUiText || !$hasRoleNoun) {
|
||||
$rawTitle = $id['primaryTitle'];
|
||||
}
|
||||
|
||||
$safeName = preg_replace('/[^\p{L}0-9\-_ ]/u', '', $id['fullName']);
|
||||
$safeTitle = preg_replace('/[^\p{L}0-9\-_ ()\/]/u', '', $rawTitle);
|
||||
$safeTitle = trim(preg_replace('/\s+/', ' ', $safeTitle));
|
||||
$fileName = trim("{$safeName} - {$safeTitle}") . '.pdf';
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user