Compare commits
46 commits
master
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd28168a5 | ||
|
|
e2abe509d0 | ||
|
|
f7e1e618a4 | ||
|
|
0b3de762d8 | ||
|
|
ac29d8369f | ||
|
|
78fb1338e8 | ||
|
|
80020e61e9 | ||
|
|
17a1aa6c5d | ||
|
|
6f0a2fdd41 | ||
|
|
c8ffc71e1e | ||
|
|
ad0f9bc1c3 | ||
|
|
003132a838 | ||
|
|
ddcbf4ca90 | ||
|
|
ca686c28aa | ||
|
|
3102ed9cc7 | ||
|
|
97bd114837 | ||
|
|
fa8cebdef5 | ||
|
|
8f8e3bd585 | ||
|
|
692cf2223c | ||
|
|
ffbf63a15d | ||
|
|
01d79c06da | ||
|
|
07a0052624 | ||
|
|
9d6d51451b | ||
|
|
cf36a8dd63 | ||
|
|
1071ef012d | ||
|
|
cb32993d5b | ||
|
|
e3ca7adb08 | ||
|
|
3bab80711b | ||
|
|
21c488eeba | ||
|
|
35daf83b92 | ||
|
|
57ce71062d | ||
|
|
c9449db0bb | ||
|
|
8f1bd9e962 | ||
|
|
63cec006bc | ||
|
|
8942f0fff2 | ||
|
|
f9c3448b86 | ||
|
|
03201fe9c0 | ||
|
|
cf48f6aa7d | ||
|
|
565b7e547c | ||
|
|
5c5f1d06ae | ||
|
|
1b77fdf39d | ||
|
|
8cd779e10e | ||
|
|
0f84aea193 | ||
|
|
ddb5e6f976 | ||
|
|
3440a66f6d | ||
|
|
797c40b9a0 |
13 changed files with 392 additions and 0 deletions
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Dependency lock files
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# VS Code settings
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
38
README.md
Normal file
38
README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# CVGen Project
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
CVGen is a Node.js application that connects to the LinkedIn API to retrieve user profile information. The application is designed to display this information on a blank page without the need for HTML or CSS.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
cvgen
|
||||||
|
├── controllers
|
||||||
|
│ └── linkedin.js # Handles LinkedIn API connection and user profile retrieval
|
||||||
|
├── routes
|
||||||
|
│ ├── api.routes.js # Defines API routes for LinkedIn authentication and data fetching
|
||||||
|
│ └── front.routes.js # Defines front-end routes (currently not utilized)
|
||||||
|
├── views # Currently empty, not needed for this project
|
||||||
|
├── public
|
||||||
|
│ └── img # Contains images (not utilized in this project)
|
||||||
|
├── app.js # Entry point of the application, sets up server and routes
|
||||||
|
├── package.json # Configuration file for npm, lists project dependencies
|
||||||
|
└── README.md # Documentation for the project
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
1. Clone the repository to your local machine.
|
||||||
|
2. Navigate to the project directory.
|
||||||
|
3. Run `npm install` to install the required dependencies.
|
||||||
|
4. Configure your LinkedIn API credentials in the `linkedin.js` controller.
|
||||||
|
5. Start the application by running `node app.js`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
- The application will connect to the LinkedIn API and retrieve user profile information.
|
||||||
|
- The retrieved information will be displayed on a blank page.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Express: A web framework for Node.js.
|
||||||
|
- LinkedIn API libraries: For connecting and interacting with the LinkedIn API.
|
||||||
|
|
||||||
|
## License
|
||||||
|
This project is licensed under the MIT License.
|
||||||
26
app.js
Normal file
26
app.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
const express = require("express");
|
||||||
|
const bodyParser = require("body-parser");
|
||||||
|
const session = require("express-session");
|
||||||
|
const apiRoutes = require("./routes/api.routes");
|
||||||
|
const frontRoutes = require("./routes/front.routes");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 4200;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
app.use(session({
|
||||||
|
secret: "your_secret_key", // change ce secret !
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use("/api", apiRoutes);
|
||||||
|
app.use("/", frontRoutes);
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
204
controllers/linkedin.js
Normal file
204
controllers/linkedin.js
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// filepath: c:\Users\amaizy\Desktop\cvgen\controllers\linkedin.js
|
||||||
|
|
||||||
|
const axios = require("axios");
|
||||||
|
const querystring = require("querystring");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
|
const clientId = "780w7gsy8eysmj";
|
||||||
|
const clientSecret = "WPL_AP1.w6OTTkAndAdT3PYF.UZEcwQ==";
|
||||||
|
const redirectUri = "http://localhost:4200/api/auth/linkedin/callback";
|
||||||
|
const scope = "openid profile email";
|
||||||
|
|
||||||
|
const email = "amaury@maizy.net";
|
||||||
|
const password = "2Qh*fJrp+l7M6g>8P~}/S$Bc2Yvf&-vd";
|
||||||
|
|
||||||
|
let accessToken = "";
|
||||||
|
|
||||||
|
const authenticateUser = (req, res) => {
|
||||||
|
const authUrl = `https://www.linkedin.com/oauth/v2/authorization?${querystring.stringify(
|
||||||
|
{
|
||||||
|
response_type: "code",
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: scope,
|
||||||
|
}
|
||||||
|
)}`;
|
||||||
|
res.redirect(authUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCallback = async (req, res) => {
|
||||||
|
const { code } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await axios.post(
|
||||||
|
"https://www.linkedin.com/oauth/v2/accessToken",
|
||||||
|
querystring.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = tokenResponse.data.access_token;
|
||||||
|
|
||||||
|
// Stocke dans la session
|
||||||
|
req.session.user = {
|
||||||
|
accessToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redirige vers /profile après authentification
|
||||||
|
res.redirect("/profile");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"LinkedIn token error:",
|
||||||
|
error.response ? error.response.data : error.message
|
||||||
|
);
|
||||||
|
res.status(500).send("Error retrieving access token.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserProfile = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const token = req.session?.user?.accessToken;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: "Aucun accessToken en session." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await axios.get("https://api.linkedin.com/v2/userinfo", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enregistre les infos utiles en session
|
||||||
|
req.session.user.nom = data.given_name + " " + data.family_name;
|
||||||
|
req.session.user.img = data.picture;
|
||||||
|
req.session.user.email = data.email;
|
||||||
|
|
||||||
|
// Redirige vers /me après authentification
|
||||||
|
res.redirect("/me");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"LinkedIn OIDC error:",
|
||||||
|
error.response?.data || error.message
|
||||||
|
);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Erreur lors de la récupération du profil." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrapeLinkedInProfile = async (user) => {
|
||||||
|
const UserImg = user.img;
|
||||||
|
const UserEmail = user.email;
|
||||||
|
const profileUrl = user.linkedinUrl;
|
||||||
|
|
||||||
|
console.log("Scraping LinkedIn profile:", profileUrl);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// === 1. Login ===
|
||||||
|
await page.goto("https://www.linkedin.com/login", {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
console.log("Login page loaded");
|
||||||
|
await page.screenshot({
|
||||||
|
path: "public/error/step1_login.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.fill("#username", email, { delay: 50 });
|
||||||
|
await page.fill("#password", password, { delay: 50 });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.click('[type="submit"]'),
|
||||||
|
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 60000 }),
|
||||||
|
]);
|
||||||
|
console.log("Logged in");
|
||||||
|
await page.screenshot({
|
||||||
|
path: "public/error/step2_logged_in.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 2. Aller sur le profil ===
|
||||||
|
await page.goto(profileUrl, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
await page.waitForSelector("h1", { timeout: 60000 });
|
||||||
|
console.log("Profile page loaded");
|
||||||
|
await page.screenshot({
|
||||||
|
path: "public/error/step3_profile_loaded.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll pour charger contenu lazy
|
||||||
|
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "public/error/step4_scrolled.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 3. Extraire les infos ===
|
||||||
|
const profileData = await page.evaluate(() => {
|
||||||
|
const getText = (selector) =>
|
||||||
|
document.querySelector(selector)?.innerText || null;
|
||||||
|
const getAllText = (selector) =>
|
||||||
|
Array.from(document.querySelectorAll(selector)).map((el) =>
|
||||||
|
el.innerText.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: getText("h1"),
|
||||||
|
headline: getText("div.text-body-medium.break-words"),
|
||||||
|
location: getText(
|
||||||
|
"span.text-body-small.inline.t-black--light.break-words"
|
||||||
|
),
|
||||||
|
// about: getText(""),
|
||||||
|
experiences: getAllText(
|
||||||
|
"div.KbuWagYZEALUPOYGtkFczhgTiHHNRLZKhdLlK > ul li"
|
||||||
|
),
|
||||||
|
// education: getAllText(""),
|
||||||
|
// skills: getAllText(""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ajoute les infos supplémentaires avant de retourner
|
||||||
|
profileData.img = UserImg || null;
|
||||||
|
profileData.email = UserEmail || null;
|
||||||
|
profileData.linkedinUrl = profileUrl || null;
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: "public/error/step5_data_extracted.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Data extracted:", profileData);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
return profileData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Erreur pendant le scraping:", err.message);
|
||||||
|
await page.screenshot({ path: "public/error/error.png", fullPage: true });
|
||||||
|
await browser.close();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
authenticateUser,
|
||||||
|
handleCallback,
|
||||||
|
getUserProfile,
|
||||||
|
scrapeLinkedInProfile,
|
||||||
|
};
|
||||||
22
package.json
Normal file
22
package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "cvgen",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A project to connect to the LinkedIn API and retrieve user profile information.",
|
||||||
|
"main": "app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node app.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"express-session": "^1.17.1",
|
||||||
|
"playwright": "^1.55.0"
|
||||||
|
},
|
||||||
|
"author": "ExostFlash",
|
||||||
|
"license": "ISC",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@git.lehub.tf:2222/MecDu.Dev/cvgen.git"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/error/error.png
Normal file
BIN
public/error/error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 907 KiB |
BIN
public/error/step1_login.png
Normal file
BIN
public/error/step1_login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
public/error/step2_logged_in.png
Normal file
BIN
public/error/step2_logged_in.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 636 KiB |
BIN
public/error/step3_profile_loaded.png
Normal file
BIN
public/error/step3_profile_loaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 888 KiB |
BIN
public/error/step4_scrolled.png
Normal file
BIN
public/error/step4_scrolled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 899 KiB |
BIN
public/error/step5_data_extracted.png
Normal file
BIN
public/error/step5_data_extracted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 906 KiB |
23
routes/api.routes.js
Normal file
23
routes/api.routes.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// filepath: c:\Users\amaizy\Desktop\cvgen\routes\api.routes.js
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const { authenticateUser, handleCallback } = require("../controllers/linkedin");
|
||||||
|
|
||||||
|
// Route for LinkedIn authentication
|
||||||
|
router.get("/auth/linkedin", authenticateUser);
|
||||||
|
|
||||||
|
// Callback route for LinkedIn OAuth2
|
||||||
|
router.get("/auth/linkedin/callback", handleCallback);
|
||||||
|
|
||||||
|
// Traitement du formulaire d'URL LinkedIn
|
||||||
|
router.post("/me/link", express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
if (!req.session.user) return res.redirect("/");
|
||||||
|
req.session.user.linkedinUrl = req.body.linkedinUrl;
|
||||||
|
res.send(`
|
||||||
|
<p>URL LinkedIn enregistrée : <a href="${req.body.linkedinUrl}" target="_blank">${req.body.linkedinUrl}</a></p>
|
||||||
|
<a href="/me">Retour</a>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
52
routes/front.routes.js
Normal file
52
routes/front.routes.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// filepath: c:\Users\amaizy\Desktop\cvgen\routes\front.routes.js
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const {
|
||||||
|
getUserProfile,
|
||||||
|
scrapeLinkedInProfile,
|
||||||
|
} = require("../controllers/linkedin");
|
||||||
|
|
||||||
|
// Page d'accueil
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
if (user) {
|
||||||
|
return res.redirect("/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(`
|
||||||
|
<h1>Welcome to the CV Generator Home Page!</h1>
|
||||||
|
<a href="/api/auth/linkedin">
|
||||||
|
<button>Connexion LinkedIn</button>
|
||||||
|
</a>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour afficher le profil utilisateur (API)
|
||||||
|
router.get("/profile", getUserProfile);
|
||||||
|
|
||||||
|
// Route protégée /me
|
||||||
|
router.get("/me", async (req, res) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
if (!user) {
|
||||||
|
return res.redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.linkedinUrl) {
|
||||||
|
return res.send(`
|
||||||
|
<h2>Bienvenue, ${user.nom} !</h2>
|
||||||
|
<form method="POST" action="/api/me/link">
|
||||||
|
<label for="linkedinUrl">Votre URL LinkedIn :</label>
|
||||||
|
<input type="url" id="linkedinUrl" name="linkedinUrl" placeholder="https://www.linkedin.com/in/votre-profil" required>
|
||||||
|
<button type="submit">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await scrapeLinkedInProfile(user);
|
||||||
|
|
||||||
|
res.send(profile);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Loading…
Add table
Reference in a new issue