linkedin-resume 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ linkedin-resume --render
32
32
  ## Usage
33
33
 
34
34
  ```
35
- Usage: linkedin-resume [options] [command]
35
+ Usage: linkedin-resume [options] [command] [outputFilepath]
36
36
 
37
37
  A CLI tool to generate a LinkedIn resume in PDF format.
38
38
 
@@ -43,7 +43,7 @@ Options:
43
43
  -V, --version output the version number
44
44
  --debug enable debug output
45
45
  --render skip scraping, only render
46
- --headless hide scraping browser window
46
+ --no-headless show scraping browser window
47
47
  --keep-open keep browser open after scraping
48
48
  -h, --help display help for command
49
49
 
@@ -54,11 +54,11 @@ Commands:
54
54
  ### Examples
55
55
 
56
56
  ```sh
57
- # Scrape and generate with browser visible (default)
57
+ # Scrape and generate in headless mode (default)
58
58
  linkedin-resume
59
59
 
60
- # Scrape in headless mode
61
- linkedin-resume --headless
60
+ # Show the browser window during scraping
61
+ linkedin-resume --no-headless
62
62
 
63
63
  # Skip scraping, re-render from previously scraped data
64
64
  linkedin-resume --render
@@ -112,11 +112,11 @@ linkedin-resume config
112
112
  "ignore": {
113
113
  // Set to true to hide the entire section, or an array to hide specific entries
114
114
  "work": [{ "name": "Company Inc.", "position": "Intern" }],
115
- "education": false,
115
+ "education": true,
116
116
  "projects": [{ "name": "Old Project" }],
117
117
  "skills": [{ "name": "Microsoft Word" }],
118
- "languages": false,
119
- "recommendations": false,
118
+ "languages": true,
119
+ "recommendations": true,
120
120
  },
121
121
  }
122
122
  ```
@@ -69,6 +69,108 @@ async function patchEsbuildHelpers(page) {
69
69
  }
70
70
  __name(patchEsbuildHelpers, "patchEsbuildHelpers");
71
71
 
72
+ // src/linkedin/utils/injectBrowserHelpers.ts
73
+ async function injectBrowserHelpers(page) {
74
+ await page.evaluate(() => {
75
+ ;
76
+ globalThis.__getTextWithBreaks = (el) => {
77
+ let text = "";
78
+ for (const node of el.childNodes) {
79
+ if (node.nodeType === Node.TEXT_NODE) {
80
+ text += node.textContent;
81
+ } else if (node.nodeName === "BR") {
82
+ text += "\n";
83
+ } else {
84
+ text += globalThis.__getTextWithBreaks(node);
85
+ }
86
+ }
87
+ return text.trim();
88
+ };
89
+ globalThis.__getVisibleSpans = (el) => {
90
+ return Array.from(el.querySelectorAll("span")).filter((span) => !span.className.includes("visually-hidden") && span.hasAttribute("aria-hidden")).map((span) => globalThis.__getTextWithBreaks(span));
91
+ };
92
+ });
93
+ }
94
+ __name(injectBrowserHelpers, "injectBrowserHelpers");
95
+
96
+ // ../../node_modules/mimic-function/index.js
97
+ var copyProperty = /* @__PURE__ */ __name((to, from, property, ignoreNonConfigurable) => {
98
+ if (property === "length" || property === "prototype") {
99
+ return;
100
+ }
101
+ if (property === "arguments" || property === "caller") {
102
+ return;
103
+ }
104
+ const toDescriptor = Object.getOwnPropertyDescriptor(to, property);
105
+ const fromDescriptor = Object.getOwnPropertyDescriptor(from, property);
106
+ if (!canCopyProperty(toDescriptor, fromDescriptor) && ignoreNonConfigurable) {
107
+ return;
108
+ }
109
+ Object.defineProperty(to, property, fromDescriptor);
110
+ }, "copyProperty");
111
+ var canCopyProperty = /* @__PURE__ */ __name(function(toDescriptor, fromDescriptor) {
112
+ return toDescriptor === void 0 || toDescriptor.configurable || toDescriptor.writable === fromDescriptor.writable && toDescriptor.enumerable === fromDescriptor.enumerable && toDescriptor.configurable === fromDescriptor.configurable && (toDescriptor.writable || toDescriptor.value === fromDescriptor.value);
113
+ }, "canCopyProperty");
114
+ var changePrototype = /* @__PURE__ */ __name((to, from) => {
115
+ const fromPrototype = Object.getPrototypeOf(from);
116
+ if (fromPrototype === Object.getPrototypeOf(to)) {
117
+ return;
118
+ }
119
+ Object.setPrototypeOf(to, fromPrototype);
120
+ }, "changePrototype");
121
+ var wrappedToString = /* @__PURE__ */ __name((withName, fromBody) => `/* Wrapped ${withName}*/
122
+ ${fromBody}`, "wrappedToString");
123
+ var toStringDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, "toString");
124
+ var toStringName = Object.getOwnPropertyDescriptor(Function.prototype.toString, "name");
125
+ var changeToString = /* @__PURE__ */ __name((to, from, name) => {
126
+ const withName = name === "" ? "" : `with ${name.trim()}() `;
127
+ const newToString = wrappedToString.bind(null, withName, from.toString());
128
+ Object.defineProperty(newToString, "name", toStringName);
129
+ const { writable, enumerable, configurable } = toStringDescriptor;
130
+ Object.defineProperty(to, "toString", { value: newToString, writable, enumerable, configurable });
131
+ }, "changeToString");
132
+ function mimicFunction(to, from, { ignoreNonConfigurable = false } = {}) {
133
+ const { name } = to;
134
+ for (const property of Reflect.ownKeys(from)) {
135
+ copyProperty(to, from, property, ignoreNonConfigurable);
136
+ }
137
+ changePrototype(to, from);
138
+ changeToString(to, from, name);
139
+ return to;
140
+ }
141
+ __name(mimicFunction, "mimicFunction");
142
+
143
+ // ../../node_modules/onetime/index.js
144
+ var calledFunctions = /* @__PURE__ */ new WeakMap();
145
+ var onetime = /* @__PURE__ */ __name((function_, options = {}) => {
146
+ if (typeof function_ !== "function") {
147
+ throw new TypeError("Expected a function");
148
+ }
149
+ let returnValue;
150
+ let callCount = 0;
151
+ const functionName = function_.displayName || function_.name || "<anonymous>";
152
+ const onetime2 = /* @__PURE__ */ __name(function(...arguments_) {
153
+ calledFunctions.set(onetime2, ++callCount);
154
+ if (callCount === 1) {
155
+ returnValue = function_.apply(this, arguments_);
156
+ function_ = void 0;
157
+ } else if (options.throw === true) {
158
+ throw new Error(`Function \`${functionName}\` can only be called once`);
159
+ }
160
+ return returnValue;
161
+ }, "onetime");
162
+ mimicFunction(onetime2, function_);
163
+ calledFunctions.set(onetime2, callCount);
164
+ return onetime2;
165
+ }, "onetime");
166
+ onetime.callCount = (function_) => {
167
+ if (!calledFunctions.has(function_)) {
168
+ throw new Error(`The given function \`${function_.name}\` is not wrapped by the \`onetime\` package`);
169
+ }
170
+ return calledFunctions.get(function_);
171
+ };
172
+ var onetime_default = onetime;
173
+
72
174
  // src/loadUserConfig.ts
73
175
  var import_promises = require("node:readline/promises");
74
176
 
@@ -89,7 +191,7 @@ var JsonFileStrategy = class {
89
191
  }
90
192
  }
91
193
  save(config) {
92
- import_fs_extra.default.outputFileSync(this.filepath, JSON.stringify(config, null, 2));
194
+ import_fs_extra.default.outputFileSync(this.filepath, JSON.stringify(config, null, 2) + "\n");
93
195
  }
94
196
  };
95
197
 
@@ -240,7 +342,7 @@ __name(getAppDataPath, "getAppDataPath");
240
342
  // src/constants.ts
241
343
  var CONFIG_PATH = getAppDataPath("bemoje", "linkedin-resume", "config.json");
242
344
  var DIST_PATH = getAppDataPath("bemoje", "linkedin-resume", "dist");
243
- var CHROME_PPROFILE_PATH = getAppDataPath("bemoje", "linkedin-resume", ".chrome-profile");
345
+ var CHROME_PROFILE_PATH = getAppDataPath("bemoje", "linkedin-resume", ".chrome-profile");
244
346
  import_fs_extra2.default.ensureDirSync(DIST_PATH);
245
347
 
246
348
  // src/userConfigFile.ts
@@ -402,10 +504,10 @@ async function loadUserConfig() {
402
504
  __name(loadUserConfig, "loadUserConfig");
403
505
 
404
506
  // src/linkedin/utils/getLinkedInUsername.ts
405
- var getLinkedInUsername = /* @__PURE__ */ __name(async () => {
507
+ var getLinkedInUsername = onetime_default(async () => {
406
508
  const userConfig = await loadUserConfig();
407
509
  return userConfig.linkedInUsername;
408
- }, "getLinkedInUsername");
510
+ });
409
511
 
410
512
  // src/linkedin/scrapeProfile.ts
411
513
  async function scrapeProfile(browser, options) {
@@ -430,6 +532,7 @@ async function scrapeProfile(browser, options) {
430
532
  await new Promise((r) => setTimeout(r, 250));
431
533
  await autoScroll(page);
432
534
  await patchEsbuildHelpers(page);
535
+ await injectBrowserHelpers(page);
433
536
  if (options.debug) {
434
537
  const debugDump = await page.evaluate(() => {
435
538
  const result2 = {};
@@ -462,19 +565,7 @@ async function scrapeProfile(browser, options) {
462
565
  };
463
566
  const imgEl = document.querySelector(".pv-top-card-profile-picture__image--show") || document.querySelector(".pv-top-card-profile-picture__image") || document.querySelector("img.profile-photo-edit__preview") || document.querySelector('img[class*="profile"][width="200"]') || document.querySelector('main img[src*="profile"]');
464
567
  const image = imgEl?.src ?? "";
465
- const getTextWithBreaks = /* @__PURE__ */ __name((el) => {
466
- let text = "";
467
- for (const node of el.childNodes) {
468
- if (node.nodeType === Node.TEXT_NODE) {
469
- text += node.textContent;
470
- } else if (node.nodeName === "BR") {
471
- text += "\n";
472
- } else {
473
- text += getTextWithBreaks(node);
474
- }
475
- }
476
- return text;
477
- }, "getTextWithBreaks");
568
+ const getTextWithBreaks = globalThis.__getTextWithBreaks;
478
569
  let summary = "";
479
570
  const aboutAnchor = document.querySelector("#about");
480
571
  if (aboutAnchor) {
@@ -651,26 +742,12 @@ async function scrapeSkills(browser, options) {
651
742
  await page.waitForSelector(".scaffold-finite-scroll__content", { timeout: 6e4 });
652
743
  await autoScroll(page);
653
744
  await patchEsbuildHelpers(page);
745
+ await injectBrowserHelpers(page);
654
746
  const rawEntries = await page.evaluate(() => {
655
747
  const container = document.querySelector(".scaffold-finite-scroll__content");
656
748
  if (!container) return [];
657
749
  const topLevelItems = Array.from(container.querySelector("ul")?.children ?? []);
658
- const getTextWithBreaks = /* @__PURE__ */ __name((el) => {
659
- let text = "";
660
- for (const node of el.childNodes) {
661
- if (node.nodeType === Node.TEXT_NODE) {
662
- text += node.textContent;
663
- } else if (node.nodeName === "BR") {
664
- text += "\n";
665
- } else {
666
- text += getTextWithBreaks(node);
667
- }
668
- }
669
- return text.trim();
670
- }, "getTextWithBreaks");
671
- const getVisibleSpans = /* @__PURE__ */ __name((el) => {
672
- return Array.from(el.querySelectorAll("span")).filter((span) => !span.className.includes("visually-hidden") && span.hasAttribute("aria-hidden")).map((span) => getTextWithBreaks(span));
673
- }, "getVisibleSpans");
750
+ const getVisibleSpans = globalThis.__getVisibleSpans;
674
751
  return topLevelItems.map((li) => ({
675
752
  spans: getVisibleSpans(li),
676
753
  logoUrl: li.querySelector("img")?.src ?? ""
@@ -803,26 +880,12 @@ async function scrapeProjects(browser, options) {
803
880
  await page.waitForSelector(".scaffold-finite-scroll__content", { timeout: 6e4 });
804
881
  await autoScroll(page);
805
882
  await patchEsbuildHelpers(page);
883
+ await injectBrowserHelpers(page);
806
884
  const rawEntries = await page.evaluate(() => {
807
885
  const container = document.querySelector(".scaffold-finite-scroll__content");
808
886
  if (!container) return [];
809
887
  const topLevelItems = Array.from(container.querySelector("ul")?.children ?? []);
810
- const getTextWithBreaks = /* @__PURE__ */ __name((el) => {
811
- let text = "";
812
- for (const node of el.childNodes) {
813
- if (node.nodeType === Node.TEXT_NODE) {
814
- text += node.textContent;
815
- } else if (node.nodeName === "BR") {
816
- text += "\n";
817
- } else {
818
- text += getTextWithBreaks(node);
819
- }
820
- }
821
- return text.trim();
822
- }, "getTextWithBreaks");
823
- const getVisibleSpans = /* @__PURE__ */ __name((el) => {
824
- return Array.from(el.querySelectorAll("span")).filter((span) => !span.className.includes("visually-hidden") && span.hasAttribute("aria-hidden")).map((span) => getTextWithBreaks(span));
825
- }, "getVisibleSpans");
888
+ const getVisibleSpans = globalThis.__getVisibleSpans;
826
889
  return topLevelItems.map((li) => {
827
890
  const mediaLinks = Array.from(li.querySelectorAll("a[href]")).filter((a) => {
828
891
  const href = a.getAttribute("href") ?? "";
@@ -938,26 +1001,12 @@ async function scrapeEducation(browser, options) {
938
1001
  await page.waitForSelector(".scaffold-finite-scroll__content", { timeout: 6e4 });
939
1002
  await autoScroll(page);
940
1003
  await patchEsbuildHelpers(page);
1004
+ await injectBrowserHelpers(page);
941
1005
  const rawEntries = await page.evaluate(() => {
942
1006
  const container = document.querySelector(".scaffold-finite-scroll__content");
943
1007
  if (!container) return [];
944
1008
  const topLevelItems = Array.from(container.querySelector("ul")?.children ?? []);
945
- const getTextWithBreaks = /* @__PURE__ */ __name((el) => {
946
- let text = "";
947
- for (const node of el.childNodes) {
948
- if (node.nodeType === Node.TEXT_NODE) {
949
- text += node.textContent;
950
- } else if (node.nodeName === "BR") {
951
- text += "\n";
952
- } else {
953
- text += getTextWithBreaks(node);
954
- }
955
- }
956
- return text.trim();
957
- }, "getTextWithBreaks");
958
- const getVisibleSpans = /* @__PURE__ */ __name((el) => {
959
- return Array.from(el.querySelectorAll("span")).filter((span) => !span.className.includes("visually-hidden") && span.hasAttribute("aria-hidden")).map((span) => getTextWithBreaks(span));
960
- }, "getVisibleSpans");
1009
+ const getVisibleSpans = globalThis.__getVisibleSpans;
961
1010
  return topLevelItems.map((li) => ({
962
1011
  spans: getVisibleSpans(li),
963
1012
  logoUrl: li.querySelector("img")?.src ?? ""
@@ -969,12 +1018,8 @@ async function scrapeEducation(browser, options) {
969
1018
  let parseEducationDate2 = function(d) {
970
1019
  if (!d) return "";
971
1020
  if (/^\d{4}$/.test(d)) return `${d}-01-01`;
972
- try {
973
- const date = new Date(Date.parse("2 " + d));
974
- return date.toISOString().slice(0, 7) + "-01";
975
- } catch {
976
- return d;
977
- }
1021
+ const parsed = parseDate(d);
1022
+ return parsed && parsed !== d ? parsed + "-01" : d;
978
1023
  };
979
1024
  var parseEducationDate = parseEducationDate2;
980
1025
  __name(parseEducationDate2, "parseEducationDate");
@@ -1083,26 +1128,12 @@ async function scrapeExperience(browser, options) {
1083
1128
  await page.waitForSelector(".scaffold-finite-scroll__content", { timeout: 6e4 });
1084
1129
  await autoScroll(page);
1085
1130
  await patchEsbuildHelpers(page);
1131
+ await injectBrowserHelpers(page);
1086
1132
  const rawEntries = await page.evaluate(() => {
1087
1133
  const container = document.querySelector(".scaffold-finite-scroll__content");
1088
1134
  if (!container) return [];
1089
1135
  const topLevelItems = Array.from(container.querySelector("ul")?.children ?? []);
1090
- const getTextWithBreaks = /* @__PURE__ */ __name((el) => {
1091
- let text = "";
1092
- for (const node of el.childNodes) {
1093
- if (node.nodeType === Node.TEXT_NODE) {
1094
- text += node.textContent;
1095
- } else if (node.nodeName === "BR") {
1096
- text += "\n";
1097
- } else {
1098
- text += getTextWithBreaks(node);
1099
- }
1100
- }
1101
- return text.trim();
1102
- }, "getTextWithBreaks");
1103
- const getVisibleSpans = /* @__PURE__ */ __name((el) => {
1104
- return Array.from(el.querySelectorAll("span")).filter((span) => !span.className.includes("visually-hidden") && span.hasAttribute("aria-hidden")).map((span) => getTextWithBreaks(span));
1105
- }, "getVisibleSpans");
1136
+ const getVisibleSpans = globalThis.__getVisibleSpans;
1106
1137
  return topLevelItems.map((li) => ({
1107
1138
  spans: getVisibleSpans(li),
1108
1139
  logoUrl: li.querySelector("img")?.src ?? ""
@@ -1168,7 +1199,7 @@ __name(scrapeExperience, "scrapeExperience");
1168
1199
  async function scrapeLinkedIn(options) {
1169
1200
  const browser = await import_puppeteer.default.launch({
1170
1201
  headless: options.headless ? "shell" : false,
1171
- userDataDir: CHROME_PPROFILE_PATH,
1202
+ userDataDir: CHROME_PROFILE_PATH,
1172
1203
  args: ["--start-maximized"],
1173
1204
  defaultViewport: null
1174
1205
  });
@@ -1887,7 +1918,8 @@ function renderLanguages(resume) {
1887
1918
  }
1888
1919
  __name(renderLanguages, "renderLanguages");
1889
1920
  function renderRecommendations(resume) {
1890
- const username = resume.basics.profiles.find((p) => p.network === "LinkedIn").username;
1921
+ const linkedInProfile = resume.basics.profiles.find((p) => p.network.toLowerCase() === "linkedin");
1922
+ const username = linkedInProfile?.username ?? "";
1891
1923
  const href = `https://www.linkedin.com/in/${username}/details/recommendations`;
1892
1924
  if (!resume.recommendations?.length) return "";
1893
1925
  return `
@@ -1936,7 +1968,7 @@ async function loadResume() {
1936
1968
  }
1937
1969
  __name(loadResume, "loadResume");
1938
1970
  function esc(s) {
1939
- return (s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1971
+ return (s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1940
1972
  }
1941
1973
  __name(esc, "esc");
1942
1974
  function formatDate(d) {
@@ -2023,12 +2055,20 @@ __name(renderResumeJson, "renderResumeJson");
2023
2055
  var import_fs_extra11 = __toESM(require("fs-extra"), 1);
2024
2056
  var import_upath4 = __toESM(require("upath"), 1);
2025
2057
  var import_url = require("url");
2058
+
2059
+ // src/utils/expandEnvVars.ts
2060
+ function expandEnvVars(filepath) {
2061
+ return filepath.replace(/\$\{(\w+)\}|\$(\w+)/g, (_2, a, b) => process.env[a || b] ?? "");
2062
+ }
2063
+ __name(expandEnvVars, "expandEnvVars");
2064
+
2065
+ // src/renderPdfFromHtml.ts
2026
2066
  var import_puppeteer2 = __toESM(require("puppeteer"), 1);
2027
2067
  async function renderPdfFromHtml(outputFilepath, options) {
2028
2068
  const htmlPath = import_upath4.default.join(DIST_PATH, "resume.html");
2029
2069
  const pdfPath = import_upath4.default.join(DIST_PATH, "resume.pdf");
2030
2070
  const htmlFileUrl = (0, import_url.pathToFileURL)(htmlPath).href;
2031
- if (!await import_fs_extra11.default.exists(htmlPath)) {
2071
+ if (!await import_fs_extra11.default.pathExists(htmlPath)) {
2032
2072
  throw new Error(`HTML file not found: ${htmlPath}. Run renderResumeHtml first.`);
2033
2073
  }
2034
2074
  await import_fs_extra11.default.ensureDir(import_upath4.default.dirname(pdfPath));
@@ -2052,12 +2092,12 @@ async function renderPdfFromHtml(outputFilepath, options) {
2052
2092
  } finally {
2053
2093
  await browser.close();
2054
2094
  }
2055
- if (!import_fs_extra11.default.existsSync(pdfPath)) {
2095
+ if (!await import_fs_extra11.default.pathExists(pdfPath)) {
2056
2096
  throw new Error(`PDF was not created at ${pdfPath}`);
2057
2097
  }
2058
2098
  console.log(`output: ${pdfPath}`);
2059
2099
  const userConfig = await loadUserConfig();
2060
- const outputFilepathToUse = outputFilepath || userConfig.outputFilepath;
2100
+ const outputFilepathToUse = expandEnvVars(outputFilepath || userConfig.outputFilepath);
2061
2101
  await import_fs_extra11.default.ensureDir(import_upath4.default.dirname(outputFilepathToUse));
2062
2102
  await import_fs_extra11.default.copy(pdfPath, outputFilepathToUse);
2063
2103
  console.log("PDF:", outputFilepathToUse);
@@ -2073,7 +2113,7 @@ var import_puppeteer3 = __toESM(require("puppeteer"), 1);
2073
2113
  async function ensureUserLoggedInToLinkedIn() {
2074
2114
  const browser = await import_puppeteer3.default.launch({
2075
2115
  headless: false,
2076
- userDataDir: CHROME_PPROFILE_PATH,
2116
+ userDataDir: CHROME_PROFILE_PATH,
2077
2117
  args: ["--start-maximized"],
2078
2118
  defaultViewport: null
2079
2119
  });
@@ -2109,11 +2149,10 @@ __name(ensureUserLoggedInToLinkedIn, "ensureUserLoggedInToLinkedIn");
2109
2149
  var description_default = "A CLI tool to generate a LinkedIn resume in PDF format.";
2110
2150
 
2111
2151
  // src/core/version.ts
2112
- var version_default = `0.2.0`;
2152
+ var version_default = `0.3.0`;
2113
2153
 
2114
2154
  // src/main.ts
2115
2155
  var cli = new import_commander.Command("linkedin-resume").version(version_default).description(description_default).argument("[outputFilepath]", "Optional filepath. Defaults to value set in config file.").option("--debug", "enable debug output").option("--render", "skip scraping, only render").option("--no-headless", "show scraping browser window").option("--keep-open", "keep browser open after scraping").action(async (outputFilepath, options) => {
2116
- options.headless = options.headless ?? true;
2117
2156
  if (options.debug) {
2118
2157
  console.log({ argv: process.argv });
2119
2158
  console.dir({ config: await loadUserConfig() }, { depth: null });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "linkedin-resume",
3
3
  "description": "A CLI tool to generate a LinkedIn resume in PDF format.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "packageManager": "yarn@4.3.1",
6
6
  "type": "module",
7
7
  "main": "dist/cli.mjs",
@@ -31,6 +31,7 @@
31
31
  "@sinclair/typebox": "^0.34.37",
32
32
  "commander": "^14.0.0",
33
33
  "fs-extra": "^11.3.0",
34
+ "onetime": "^7.0.0",
34
35
  "puppeteer": "^24.37.3",
35
36
  "upath": "^2.0.1"
36
37
  },