Compare commits
94 Commits
bf2b1c8ec5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83506f00a2 | ||
|
|
43c5f8d0a7 | ||
|
|
3de45d7f43 | ||
|
|
97fac48bfd | ||
|
|
57784c790a | ||
|
|
728e45c065 | ||
|
|
4516c2878f | ||
|
|
2e1df70eaf | ||
|
|
ae2cb47c87 | ||
|
|
eaca0ce6dd | ||
|
|
b179a63407 | ||
|
|
5a76223949 | ||
|
|
e28e4f91dc | ||
|
|
58798e784c | ||
|
|
137bf43dc8 | ||
|
|
a3b8732ba2 | ||
|
|
6d054901dc | ||
|
|
a615ee53af | ||
|
|
2f71793499 | ||
|
|
4bab69d2b1 | ||
|
|
6d4337bccf | ||
|
|
979a5bbdae | ||
|
|
8ebbedad83 | ||
|
|
6601d7314f | ||
|
|
7cf9b474bb | ||
|
|
96986eb302 | ||
|
|
6f6cec0617 | ||
|
|
a0c956de21 | ||
|
|
7bbeda0af6 | ||
|
|
5c975b0a6b | ||
|
|
36e4dd42e4 | ||
|
|
d5919fbf01 | ||
|
|
873b965f90 | ||
|
|
671b0fb927 | ||
|
|
1e16cafa74 | ||
|
|
a6016f3f8f | ||
|
|
8f1ab9174a | ||
|
|
fa30682463 | ||
|
|
e153327bba | ||
|
|
dad9cba7db | ||
|
|
64bd970d9a | ||
|
|
1711d5ec1d | ||
|
|
50b021b89e | ||
|
|
9dbdae8684 | ||
|
|
76113e7d84 | ||
|
|
6152ff2fbe | ||
|
|
443b61865e | ||
|
|
44eeecc3e8 | ||
|
|
ca29b731ff | ||
|
|
6c46128168 | ||
|
|
9b7843ebb0 | ||
|
|
3e11488c39 | ||
|
|
a7356556d4 | ||
|
|
c1282addc2 | ||
|
|
3e470e4c06 | ||
|
|
e9c3d222fd | ||
|
|
bdefd51b31 | ||
|
|
29c6f974f0 | ||
|
|
3554c5b358 | ||
|
|
c5f8a3e356 | ||
|
|
8c221402e0 | ||
|
|
0ab5dd79b6 | ||
|
|
b3f5f90d0d | ||
|
|
6a3fdc807e | ||
|
|
29dac58464 | ||
|
|
470580ba05 | ||
|
|
645638010d | ||
|
|
a8457bf7d8 | ||
|
|
a238d80201 | ||
|
|
645b2bccf1 | ||
|
|
c0465e4ee4 | ||
|
|
42aec81504 | ||
|
|
6c5fd21be6 | ||
|
|
94d430b972 | ||
|
|
f3b04c1c4c | ||
|
|
0267dc698c | ||
|
|
1b930f92be | ||
|
|
98c890ef16 | ||
|
|
9bf1406796 | ||
|
|
3ac8260c1d | ||
|
|
a7a782c422 | ||
|
|
26fc873e85 | ||
|
|
2548dd1331 | ||
|
|
5fd6969ff8 | ||
|
|
8dc55c698e | ||
|
|
42edf6d636 | ||
|
|
8507032845 | ||
|
|
cacb756425 | ||
|
|
3d0b1cc36e | ||
|
|
acc25dfabc | ||
|
|
dd5d1980cf | ||
|
|
b65db1e20b | ||
|
|
8844a38e9e | ||
|
|
a94e219f39 |
127
background.js
127
background.js
@@ -1,10 +1,10 @@
|
|||||||
// background.js — Service Worker
|
// background.js — Service Worker
|
||||||
// Handles all Gemini API calls (avoids CORS issues from content scripts)
|
// Handles all Gemini API calls (avoids CORS issues from content scripts)
|
||||||
|
|
||||||
const GEMINI_MODEL = 'gemini-2.5-flash';
|
const GEMINI_MODEL = 'gemini-flash-lite-latest';
|
||||||
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
||||||
|
|
||||||
// ─── Message listener ────────────────────────────────────────────────────────
|
// (Offscreen document logic removed in favor of iframe approach)
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (message.type === 'GEMINI_REQUEST') {
|
if (message.type === 'GEMINI_REQUEST') {
|
||||||
@@ -13,29 +13,41 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
.catch(err => sendResponse({ success: false, error: err.message }));
|
.catch(err => sendResponse({ success: false, error: err.message }));
|
||||||
return true; // Keep message channel open for async
|
return true; // Keep message channel open for async
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === 'GEMINI_PROCESS_VOICE') {
|
||||||
|
handleVoiceProcessing(message.payload)
|
||||||
|
.then(result => sendResponse({ success: true, data: result }))
|
||||||
|
.catch(err => sendResponse({ success: false, error: err.message }));
|
||||||
|
return true; // Keep message channel open for async
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'OPEN_PERMISSION_PAGE') {
|
||||||
|
chrome.tabs.create({ url: chrome.runtime.getURL('permission.html') });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Core API call ───────────────────────────────────────────────────────────
|
// ─── Core API call ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleGeminiRequest({ apiKey, prompt, tab, action = 'generateText', jobDescription = '', jobTitle = '' }) {
|
async function handleGeminiRequest({ apiKey, prompt, tab, action = 'generateText', jobDescription = '', jobTitle = '', postText = '', template = 'default' }) {
|
||||||
// Rate limit check
|
// Rate limit check
|
||||||
const canProceed = await checkRateLimit();
|
const canProceed = await checkRateLimit();
|
||||||
if (!canProceed) {
|
if (!canProceed) {
|
||||||
throw new Error('Daily limit reached (1,000 requests). Resets at midnight PT.');
|
throw new Error('Daily limit reached (1,000 requests). Resets at midnight PT.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate text
|
// Truncate text — 12k chars to fit list_analysis prompts
|
||||||
const maxChars = 6000;
|
const maxChars = 12000;
|
||||||
const trimmedPrompt = prompt && prompt.length > maxChars
|
const trimmedPrompt = prompt && prompt.length > maxChars
|
||||||
? prompt.substring(0, maxChars) + '\n\n[Truncated]'
|
? prompt.substring(0, maxChars) + '\n\n[Truncated]'
|
||||||
: prompt;
|
: prompt;
|
||||||
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const RETRY_DELAYS = [2000, 20000, 30000];
|
const RETRY_DELAYS = [0, 15000, 30000]; // No delay on first attempt
|
||||||
let lastError = '';
|
let lastError = '';
|
||||||
|
|
||||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
await delay(RETRY_DELAYS[attempt]);
|
if (RETRY_DELAYS[attempt] > 0) await delay(RETRY_DELAYS[attempt]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://cv.intaleqapp.com/cv/server/generate_cv.php', {
|
const response = await fetch('https://cv.intaleqapp.com/cv/server/generate_cv.php', {
|
||||||
@@ -46,21 +58,42 @@ async function handleGeminiRequest({ apiKey, prompt, tab, action = 'generateText
|
|||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
prompt: trimmedPrompt,
|
prompt: trimmedPrompt,
|
||||||
jobDescription: jobDescription,
|
jobDescription: jobDescription,
|
||||||
jobTitle: jobTitle
|
jobTitle: jobTitle,
|
||||||
|
postText: postText,
|
||||||
|
template: template
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (action === 'generatePdf') {
|
if (action === 'generatePdf') {
|
||||||
if (!data.pdf) throw new Error('Empty PDF response from server.');
|
if (!data.pdf) throw new Error('Empty PDF response from server.');
|
||||||
await incrementUsage();
|
await incrementUsage();
|
||||||
return { pdf: data.pdf, filename: data.filename, fromCache: false };
|
return { pdf: data.pdf, filename: data.filename, fromCache: false };
|
||||||
|
} else if (action === 'generateComment') {
|
||||||
|
const comment = data.comment;
|
||||||
|
if (!comment) {
|
||||||
|
console.error('[LJA-BG] Empty comment. Full response:', JSON.stringify(data).substring(0, 300));
|
||||||
|
lastError = 'Empty comment from server.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await incrementUsage();
|
||||||
|
return { comment, fromCache: false };
|
||||||
|
} else if (action === 'repurposePost') {
|
||||||
|
const result = data.result;
|
||||||
|
if (!result) {
|
||||||
|
console.error('[LJA-BG] Empty result. Full response:', JSON.stringify(data).substring(0, 300));
|
||||||
|
lastError = 'Empty result from server.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await incrementUsage();
|
||||||
|
return { result, fromCache: false };
|
||||||
} else {
|
} else {
|
||||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
if (!text) {
|
if (!text) {
|
||||||
lastError = 'Empty response from API.';
|
console.error('[LJA-BG] Empty text. Full response:', JSON.stringify(data).substring(0, 500));
|
||||||
|
lastError = 'Empty response from API. Server returned: ' + JSON.stringify(data).substring(0, 200);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await incrementUsage();
|
await incrementUsage();
|
||||||
@@ -170,3 +203,77 @@ function storageGet(keys) {
|
|||||||
function storageSet(data) {
|
function storageSet(data) {
|
||||||
return new Promise(resolve => chrome.storage.local.set(data, resolve));
|
return new Promise(resolve => chrome.storage.local.set(data, resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Voice Processing (for Claude Arabic Voice extension) ────────────────────
|
||||||
|
|
||||||
|
async function handleVoiceProcessing({ apiKey, model, text, language }) {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('Gemini API key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if text is Arabic
|
||||||
|
const isArabic = /[\u0600-\u06FF]/.test(text);
|
||||||
|
const langName = isArabic ? 'Arabic' : 'the detected language';
|
||||||
|
|
||||||
|
const prompt = `You are a text refinement assistant. Your task is to:
|
||||||
|
|
||||||
|
1. Correct any speech recognition errors in the following text
|
||||||
|
2. Fix punctuation, capitalization, and formatting
|
||||||
|
3. Keep the original meaning and content intact
|
||||||
|
4. Do NOT add any new information or commentary
|
||||||
|
5. Return ONLY the corrected text, nothing else
|
||||||
|
|
||||||
|
The text is in ${langName}. Preserve the original language.
|
||||||
|
|
||||||
|
TEXT TO REFINE:
|
||||||
|
${text}
|
||||||
|
|
||||||
|
CORRECTED TEXT:`;
|
||||||
|
|
||||||
|
const response = await fetch('https://cv.intaleqapp.com/cv/server/generate_cv.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'generateText',
|
||||||
|
apiKey: apiKey,
|
||||||
|
prompt: prompt
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => '');
|
||||||
|
let errMsg;
|
||||||
|
try {
|
||||||
|
const errData = JSON.parse(errText);
|
||||||
|
errMsg = errData.error?.message || `HTTP ${response.status}`;
|
||||||
|
} catch (e) {
|
||||||
|
errMsg = `HTTP ${response.status}: ${errText.substring(0, 200)}`;
|
||||||
|
}
|
||||||
|
throw new Error(`Gemini API error: ${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch (e) {
|
||||||
|
return { text: responseText.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.candidates && data.candidates[0]) {
|
||||||
|
const resultText = data.candidates[0].content?.parts?.[0]?.text;
|
||||||
|
if (resultText) {
|
||||||
|
return { text: resultText.trim() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`Server error: ${data.error}${data.details ? ' - ' + JSON.stringify(data.details) : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return { text: data.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Empty response from Gemini API');
|
||||||
|
}
|
||||||
|
|||||||
114
claude-arabic-voice/background.js
Normal file
114
claude-arabic-voice/background.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// background.js — Service Worker for Claude Arabic Voice
|
||||||
|
// Handles Gemini API calls for voice text processing via the existing server proxy
|
||||||
|
|
||||||
|
const SERVER_URL = 'https://cv.intaleqapp.com/cv/server/generate_cv.php';
|
||||||
|
|
||||||
|
// ─── Message Listener ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
if (message.type === 'GEMINI_PROCESS_VOICE') {
|
||||||
|
handleVoiceProcessing(message.payload)
|
||||||
|
.then(result => sendResponse({ success: true, data: result }))
|
||||||
|
.catch(err => sendResponse({ success: false, error: err.message }));
|
||||||
|
return true; // Keep message channel open for async
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Voice Text Processing with Gemini (via server proxy) ────────────────────
|
||||||
|
|
||||||
|
async function handleVoiceProcessing({ apiKey, model, text, language }) {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('Gemini API key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if text is Arabic
|
||||||
|
const isArabic = /[\u0600-\u06FF]/.test(text);
|
||||||
|
const langName = isArabic ? 'Arabic' : 'the detected language';
|
||||||
|
|
||||||
|
const prompt = `You are a text refinement assistant. Your task is to:
|
||||||
|
|
||||||
|
1. Correct any speech recognition errors in the following text
|
||||||
|
2. Fix punctuation, capitalization, and formatting
|
||||||
|
3. Keep the original meaning and content intact
|
||||||
|
4. Do NOT add any new information or commentary
|
||||||
|
5. Return ONLY the corrected text, nothing else
|
||||||
|
|
||||||
|
The text is in ${langName}. Preserve the original language.
|
||||||
|
|
||||||
|
TEXT TO REFINE:
|
||||||
|
${text}
|
||||||
|
|
||||||
|
CORRECTED TEXT:`;
|
||||||
|
|
||||||
|
console.log('[ClaudeVoice] Sending to server:', SERVER_URL);
|
||||||
|
|
||||||
|
const response = await fetch(SERVER_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'generateText',
|
||||||
|
apiKey: apiKey,
|
||||||
|
prompt: prompt
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => '');
|
||||||
|
let errMsg;
|
||||||
|
try {
|
||||||
|
const errData = JSON.parse(errText);
|
||||||
|
errMsg = errData.error?.message || `HTTP ${response.status}`;
|
||||||
|
} catch (e) {
|
||||||
|
errMsg = `HTTP ${response.status}: ${errText.substring(0, 200)}`;
|
||||||
|
}
|
||||||
|
throw new Error(`Gemini API error: ${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
console.log('[ClaudeVoice] Server response received, length:', responseText.length);
|
||||||
|
|
||||||
|
// Try to parse as JSON (Gemini response format from server proxy)
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch (e) {
|
||||||
|
// If not JSON, use the raw text
|
||||||
|
return { text: responseText.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a Gemini API response format
|
||||||
|
if (data.candidates && data.candidates[0]) {
|
||||||
|
const resultText = data.candidates[0].content?.parts?.[0]?.text;
|
||||||
|
if (resultText) {
|
||||||
|
return { text: resultText.trim() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's our server's error format
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`Server error: ${data.error}${data.details ? ' - ' + JSON.stringify(data.details) : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got here but have some text, return it
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return { text: data.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Empty response from Gemini API');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Installation Handler ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener((details) => {
|
||||||
|
if (details.reason === 'install') {
|
||||||
|
// Set default settings
|
||||||
|
chrome.storage.sync.set({
|
||||||
|
language: 'ar-SA',
|
||||||
|
autoSend: false,
|
||||||
|
useGemini: false,
|
||||||
|
geminiApiKey: '',
|
||||||
|
geminiModel: 'gemini-flash-lite-latest'
|
||||||
|
});
|
||||||
|
console.log('[ClaudeVoice] ✅ Extension installed with default settings');
|
||||||
|
}
|
||||||
|
});
|
||||||
651
claude-arabic-voice/content.js
Normal file
651
claude-arabic-voice/content.js
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
// content.js — Arabic Voice Input for Claude.ai
|
||||||
|
// Injects a microphone button that uses Web Speech API for Arabic speech recognition
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Prevent double injection
|
||||||
|
if (window.__claudeArabicVoiceLoaded) return;
|
||||||
|
window.__claudeArabicVoiceLoaded = true;
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────────
|
||||||
|
let recognition = null;
|
||||||
|
let isListening = false;
|
||||||
|
let micButton = null;
|
||||||
|
let statusIndicator = null;
|
||||||
|
let interimText = '';
|
||||||
|
let finalText = '';
|
||||||
|
let autoSendTimer = null;
|
||||||
|
let silenceTimer = null;
|
||||||
|
let lastSpeechTime = 0;
|
||||||
|
let isGeminiProcessing = false;
|
||||||
|
|
||||||
|
const SILENCE_TIMEOUT = 2000; // 2 seconds of silence = auto-stop
|
||||||
|
const MAX_RECORDING_TIME = 30000; // 30 seconds max recording
|
||||||
|
|
||||||
|
// ─── Settings ────────────────────────────────────────────────────────────
|
||||||
|
let settings = {
|
||||||
|
language: 'ar-SA', // Default: Arabic (Saudi Arabia)
|
||||||
|
autoSend: false, // Auto-send after stopping
|
||||||
|
useGemini: false, // Use Gemini AI to refine text
|
||||||
|
geminiApiKey: '',
|
||||||
|
geminiModel: 'gemini-flash-lite-latest'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Load Settings ───────────────────────────────────────────────────────
|
||||||
|
async function loadSettings() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.sync.get(
|
||||||
|
['language', 'autoSend', 'useGemini', 'geminiApiKey', 'geminiModel'],
|
||||||
|
(data) => {
|
||||||
|
if (data.language) settings.language = data.language;
|
||||||
|
if (data.autoSend !== undefined) settings.autoSend = data.autoSend;
|
||||||
|
if (data.useGemini !== undefined) settings.useGemini = data.useGemini;
|
||||||
|
if (data.geminiApiKey) settings.geminiApiKey = data.geminiApiKey;
|
||||||
|
if (data.geminiModel) settings.geminiModel = data.geminiModel;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Iframe Integration ──────────────────────────────────────────────────
|
||||||
|
let speechIframe = null;
|
||||||
|
|
||||||
|
function initSpeechIframe() {
|
||||||
|
if (speechIframe) return;
|
||||||
|
|
||||||
|
speechIframe = document.createElement('iframe');
|
||||||
|
speechIframe.id = 'claude-voice-speech-iframe';
|
||||||
|
speechIframe.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border: none;
|
||||||
|
`;
|
||||||
|
speechIframe.src = chrome.runtime.getURL('claude-arabic-voice/speech.html');
|
||||||
|
speechIframe.allow = 'microphone';
|
||||||
|
document.body.appendChild(speechIframe);
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.source !== speechIframe.contentWindow) return;
|
||||||
|
|
||||||
|
const message = event.data;
|
||||||
|
if (message.type === 'SPEECH_START_SUCCESS') {
|
||||||
|
isListening = true;
|
||||||
|
lastSpeechTime = Date.now();
|
||||||
|
interimText = '';
|
||||||
|
finalText = '';
|
||||||
|
updateMicButton(true);
|
||||||
|
showStatus('listening', '🎤 جارٍ الاستماع...');
|
||||||
|
|
||||||
|
clearTimeout(autoSendTimer);
|
||||||
|
autoSendTimer = setTimeout(() => {
|
||||||
|
if (isListening) stopListening();
|
||||||
|
}, MAX_RECORDING_TIME);
|
||||||
|
|
||||||
|
clearTimeout(silenceTimer);
|
||||||
|
silenceTimer = setTimeout(checkSilence, SILENCE_TIMEOUT);
|
||||||
|
} else if (message.type === 'SPEECH_RESULT') {
|
||||||
|
lastSpeechTime = Date.now();
|
||||||
|
interimText = message.payload.interimText || '';
|
||||||
|
finalText = message.payload.finalText || '';
|
||||||
|
|
||||||
|
updateInputField();
|
||||||
|
|
||||||
|
clearTimeout(silenceTimer);
|
||||||
|
silenceTimer = setTimeout(checkSilence, SILENCE_TIMEOUT);
|
||||||
|
} else if (message.type === 'SPEECH_ERROR') {
|
||||||
|
console.error('[ClaudeVoice] Recognition error:', message.payload.error);
|
||||||
|
if (message.payload.error === 'no-speech') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopListening();
|
||||||
|
|
||||||
|
if (message.payload.error === 'not-allowed') {
|
||||||
|
showStatus('error', '❌ الرجاء منح الصلاحية (تم فتح صفحة جديدة)');
|
||||||
|
chrome.runtime.sendMessage({ type: 'OPEN_PERMISSION_PAGE' });
|
||||||
|
} else {
|
||||||
|
showStatus('error', '❌ خطأ: ' + getArabicError(message.payload.error));
|
||||||
|
}
|
||||||
|
} else if (message.type === 'SPEECH_END') {
|
||||||
|
if (isListening) stopListening();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSilence() {
|
||||||
|
if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) {
|
||||||
|
stopListening();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArabicError(error) {
|
||||||
|
const errors = {
|
||||||
|
'no-speech': 'لم يتم اكتشاف كلام',
|
||||||
|
'aborted': 'تم الإلغاء',
|
||||||
|
'audio-capture': 'الميكروفون غير متاح',
|
||||||
|
'network': 'خطأ في الشبكة',
|
||||||
|
'not-allowed': 'الرجاء السماح باستخدام الميكروفون',
|
||||||
|
'service-not-allowed': 'الخدمة غير متاحة',
|
||||||
|
'bad-grammar': 'خطأ في القواعد'
|
||||||
|
};
|
||||||
|
return errors[error] || error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Start / Stop Listening ─────────────────────────────────────────────
|
||||||
|
async function startListening() {
|
||||||
|
await loadSettings();
|
||||||
|
if (!speechIframe) initSpeechIframe();
|
||||||
|
|
||||||
|
try {
|
||||||
|
speechIframe.contentWindow.postMessage({
|
||||||
|
type: 'START_RECORDING',
|
||||||
|
payload: { language: settings.language }
|
||||||
|
}, '*');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ClaudeVoice] Start message failed:', e);
|
||||||
|
showStatus('error', '❌ فشل الاتصال بالإضافة');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopListening() {
|
||||||
|
if (!isListening) return;
|
||||||
|
|
||||||
|
isListening = false;
|
||||||
|
clearTimeout(silenceTimer);
|
||||||
|
clearTimeout(autoSendTimer);
|
||||||
|
|
||||||
|
if (speechIframe) {
|
||||||
|
speechIframe.contentWindow.postMessage({ type: 'STOP_RECORDING' }, '*');
|
||||||
|
}
|
||||||
|
updateMicButton(false);
|
||||||
|
|
||||||
|
const text = finalText.trim() || interimText.trim();
|
||||||
|
if (text) {
|
||||||
|
showStatus('processing', '⏳ جارٍ المعالجة...');
|
||||||
|
|
||||||
|
if (settings.useGemini && settings.geminiApiKey) {
|
||||||
|
await processWithGemini(text);
|
||||||
|
} else {
|
||||||
|
insertTextIntoClaude(text);
|
||||||
|
showStatus('done', '✅ تم الإدراج');
|
||||||
|
setTimeout(() => hideStatus(), 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showStatus('idle', '🎤 اضغط للتحدث');
|
||||||
|
setTimeout(() => hideStatus(), 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Update Claude Input ─────────────────────────────────────────────────
|
||||||
|
function updateInputField() {
|
||||||
|
const fullText = (finalText + interimText).trim();
|
||||||
|
if (!fullText) return;
|
||||||
|
|
||||||
|
// Find Claude's input area
|
||||||
|
const inputArea = findClaudeInput();
|
||||||
|
if (!inputArea) return;
|
||||||
|
|
||||||
|
// For interim results, we update a placeholder
|
||||||
|
// For final results, we insert the text
|
||||||
|
if (interimText) {
|
||||||
|
// Show interim in a floating preview
|
||||||
|
showInterimPreview(interimText);
|
||||||
|
} else {
|
||||||
|
hideInterimPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClaudeInput() {
|
||||||
|
// Claude.ai uses a contenteditable div or textarea
|
||||||
|
// Try multiple selectors as Claude's UI may change
|
||||||
|
const selectors = [
|
||||||
|
'[contenteditable="true"][role="textbox"]',
|
||||||
|
'[contenteditable="true"].ProseMirror',
|
||||||
|
'div[contenteditable="true"]',
|
||||||
|
'textarea[placeholder*="Message"]',
|
||||||
|
'textarea[placeholder*="Ask"]',
|
||||||
|
'textarea[placeholder*="مراسلة"]',
|
||||||
|
'textarea[placeholder*="اسأل"]',
|
||||||
|
'.ProseMirror[contenteditable="true"]',
|
||||||
|
'div[role="textbox"][contenteditable="true"]'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el) return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSendButton() {
|
||||||
|
const selectors = [
|
||||||
|
'button[aria-label*="Send"]',
|
||||||
|
'button[aria-label*="إرسال"]',
|
||||||
|
'button[data-testid*="send"]',
|
||||||
|
'button[class*="send"]',
|
||||||
|
'button[class*="Send"]',
|
||||||
|
'form button[type="submit"]',
|
||||||
|
// Claude's specific send button
|
||||||
|
'button:has(svg[class*="send"])',
|
||||||
|
'button:has(svg[data-icon*="arrow"])'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el) return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTextIntoClaude(text) {
|
||||||
|
const inputArea = findClaudeInput();
|
||||||
|
if (!inputArea) {
|
||||||
|
showStatus('error', '❌ لم يتم العثور على حقل الإدخال');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a contenteditable div (ProseMirror) or textarea
|
||||||
|
if (inputArea.isContentEditable || inputArea.tagName === 'DIV') {
|
||||||
|
// For contenteditable (Claude's ProseMirror editor)
|
||||||
|
// Insert text at cursor position or append
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
|
||||||
|
// Focus the input area
|
||||||
|
inputArea.focus();
|
||||||
|
|
||||||
|
// Try to place cursor at end
|
||||||
|
if (inputArea.lastChild) {
|
||||||
|
range.setStartAfter(inputArea.lastChild);
|
||||||
|
range.setEndAfter(inputArea.lastChild);
|
||||||
|
} else {
|
||||||
|
range.selectNodeContents(inputArea);
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
// Insert text
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
|
||||||
|
// Dispatch input event for React/ProseMirror to detect
|
||||||
|
inputArea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
inputArea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
} else if (inputArea.tagName === 'TEXTAREA' || inputArea.tagName === 'INPUT') {
|
||||||
|
// For textarea/input
|
||||||
|
const start = inputArea.selectionStart || inputArea.value.length;
|
||||||
|
const end = inputArea.selectionEnd || inputArea.value.length;
|
||||||
|
inputArea.value = inputArea.value.substring(0, start) + text + inputArea.value.substring(end);
|
||||||
|
inputArea.selectionStart = inputArea.selectionEnd = start + text.length;
|
||||||
|
inputArea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
inputArea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-send if enabled
|
||||||
|
if (settings.autoSend) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const sendBtn = findSendButton();
|
||||||
|
if (sendBtn && !sendBtn.disabled) {
|
||||||
|
sendBtn.click();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Interim Preview ─────────────────────────────────────────────────────
|
||||||
|
let previewEl = null;
|
||||||
|
|
||||||
|
function showInterimPreview(text) {
|
||||||
|
if (!previewEl) {
|
||||||
|
previewEl = document.createElement('div');
|
||||||
|
previewEl.id = 'claude-voice-interim';
|
||||||
|
previewEl.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 100px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 999999;
|
||||||
|
direction: rtl;
|
||||||
|
font-family: 'Segoe UI', Tahoma, sans-serif;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(previewEl);
|
||||||
|
}
|
||||||
|
previewEl.textContent = '🎤 ' + text + ' ▊';
|
||||||
|
previewEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideInterimPreview() {
|
||||||
|
if (previewEl) {
|
||||||
|
previewEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Gemini AI Processing ────────────────────────────────────────────────
|
||||||
|
async function processWithGemini(text) {
|
||||||
|
isGeminiProcessing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let processedText = null;
|
||||||
|
|
||||||
|
// Try direct fetch to server (more reliable than chrome.runtime.sendMessage)
|
||||||
|
try {
|
||||||
|
const isArabic = /[\u0600-\u06FF]/.test(text);
|
||||||
|
const langName = isArabic ? 'Arabic' : 'the detected language';
|
||||||
|
|
||||||
|
const prompt = `You are a text refinement assistant. Your task is to:
|
||||||
|
|
||||||
|
1. Correct any speech recognition errors in the following text
|
||||||
|
2. Fix punctuation, capitalization, and formatting
|
||||||
|
3. Keep the original meaning and content intact
|
||||||
|
4. Do NOT add any new information or commentary
|
||||||
|
5. Return ONLY the corrected text, nothing else
|
||||||
|
|
||||||
|
The text is in ${langName}. Preserve the original language.
|
||||||
|
|
||||||
|
TEXT TO REFINE:
|
||||||
|
${text}
|
||||||
|
|
||||||
|
CORRECTED TEXT:`;
|
||||||
|
|
||||||
|
const directResponse = await fetch('https://cv.intaleqapp.com/cv/server/generate_cv.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'generateText',
|
||||||
|
apiKey: settings.geminiApiKey,
|
||||||
|
prompt: prompt
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directResponse.ok) {
|
||||||
|
const rawText = await directResponse.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawText);
|
||||||
|
} catch (e) {
|
||||||
|
data = rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.candidates && data.candidates[0]) {
|
||||||
|
processedText = data.candidates[0].content?.parts?.[0]?.text;
|
||||||
|
} else if (typeof data === 'string') {
|
||||||
|
processedText = data;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[ClaudeVoice] Server fetch failed:', directResponse.status);
|
||||||
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
console.warn('[ClaudeVoice] Server fetch error:', fetchErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedText) {
|
||||||
|
insertTextIntoClaude(processedText);
|
||||||
|
showStatus('done', '✅ تمت المعالجة بواسطة Gemini');
|
||||||
|
} else {
|
||||||
|
insertTextIntoClaude(text);
|
||||||
|
showStatus('done', '✅ تم الإدراج (بدون معالجة)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ClaudeVoice] Gemini error:', e);
|
||||||
|
insertTextIntoClaude(text);
|
||||||
|
showStatus('done', '✅ تم الإدراج (بدون معالجة)');
|
||||||
|
}
|
||||||
|
|
||||||
|
isGeminiProcessing = false;
|
||||||
|
setTimeout(() => hideStatus(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UI: Mic Button ──────────────────────────────────────────────────────
|
||||||
|
function createMicButton() {
|
||||||
|
if (micButton) return;
|
||||||
|
|
||||||
|
micButton = document.createElement('button');
|
||||||
|
micButton.id = 'claude-voice-mic-btn';
|
||||||
|
micButton.innerHTML = '🎤';
|
||||||
|
micButton.title = 'إملاء صوتي عربي - اضغط للتحدث';
|
||||||
|
micButton.setAttribute('aria-label', 'إملاء صوتي عربي');
|
||||||
|
|
||||||
|
micButton.addEventListener('click', () => {
|
||||||
|
if (isListening) {
|
||||||
|
stopListening();
|
||||||
|
} else {
|
||||||
|
startListening();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status indicator
|
||||||
|
statusIndicator = document.createElement('div');
|
||||||
|
statusIndicator.id = 'claude-voice-status';
|
||||||
|
statusIndicator.textContent = '🎤 اضغط للتحدث';
|
||||||
|
statusIndicator.style.display = 'none';
|
||||||
|
|
||||||
|
// Container
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'claude-voice-container';
|
||||||
|
container.appendChild(micButton);
|
||||||
|
container.appendChild(statusIndicator);
|
||||||
|
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Position the button near Claude's input area
|
||||||
|
positionMicButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionMicButton() {
|
||||||
|
// Try to find the input area and position near it
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const inputArea = findClaudeInput();
|
||||||
|
if (inputArea && micButton) {
|
||||||
|
const rect = inputArea.getBoundingClientRect();
|
||||||
|
if (rect && rect.top > 0) {
|
||||||
|
micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||||
|
micButton.style.right = '20px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial positioning
|
||||||
|
setTimeout(() => {
|
||||||
|
const inputArea = findClaudeInput();
|
||||||
|
if (inputArea && micButton) {
|
||||||
|
const rect = inputArea.getBoundingClientRect();
|
||||||
|
if (rect && rect.top > 0) {
|
||||||
|
micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMicButton(listening) {
|
||||||
|
if (!micButton) return;
|
||||||
|
|
||||||
|
if (listening) {
|
||||||
|
micButton.classList.add('listening');
|
||||||
|
micButton.innerHTML = '🔴';
|
||||||
|
micButton.title = 'إيقاف التسجيل';
|
||||||
|
} else {
|
||||||
|
micButton.classList.remove('listening');
|
||||||
|
micButton.innerHTML = '🎤';
|
||||||
|
micButton.title = 'إملاء صوتي عربي - اضغط للتحدث';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(type, message) {
|
||||||
|
if (!statusIndicator) return;
|
||||||
|
statusIndicator.textContent = message;
|
||||||
|
statusIndicator.className = 'claude-voice-status-' + type;
|
||||||
|
statusIndicator.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStatus() {
|
||||||
|
if (!statusIndicator) return;
|
||||||
|
statusIndicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inject Styles ───────────────────────────────────────────────────────
|
||||||
|
function injectStyles() {
|
||||||
|
// Styles are loaded from styles.css via manifest
|
||||||
|
// But we also inject some critical inline styles
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#claude-voice-container {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 999999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
bottom: 120px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
#claude-voice-mic-btn {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #6c63ff, #4834d4);
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 15px rgba(108, 99, 255, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#claude-voice-mic-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 6px 20px rgba(108, 99, 255, 0.6);
|
||||||
|
}
|
||||||
|
#claude-voice-mic-btn.listening {
|
||||||
|
background: linear-gradient(135deg, #ff4444, #cc0000);
|
||||||
|
animation: claude-voice-pulse 1.5s infinite;
|
||||||
|
box-shadow: 0 4px 20px rgba(255, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
@keyframes claude-voice-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0.6); }
|
||||||
|
50% { box-shadow: 0 0 0 15px rgba(255, 68, 68, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0); }
|
||||||
|
}
|
||||||
|
#claude-voice-status {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Segoe UI', Tahoma, sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
direction: rtl;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.claude-voice-status-listening {
|
||||||
|
background: rgba(255, 68, 68, 0.9) !important;
|
||||||
|
}
|
||||||
|
.claude-voice-status-processing {
|
||||||
|
background: rgba(255, 165, 0, 0.9) !important;
|
||||||
|
}
|
||||||
|
.claude-voice-status-done {
|
||||||
|
background: rgba(0, 200, 83, 0.9) !important;
|
||||||
|
}
|
||||||
|
.claude-voice-status-error {
|
||||||
|
background: rgba(255, 0, 0, 0.9) !important;
|
||||||
|
}
|
||||||
|
.claude-voice-status-idle {
|
||||||
|
background: rgba(108, 99, 255, 0.9) !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message Listener (for popup communication) ─────────────────────────
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
if (message.type === 'GET_STATUS') {
|
||||||
|
sendResponse({ isListening });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === 'SETTINGS_UPDATED') {
|
||||||
|
if (message.payload.language) settings.language = message.payload.language;
|
||||||
|
if (message.payload.autoSend !== undefined) settings.autoSend = message.payload.autoSend;
|
||||||
|
if (message.payload.useGemini !== undefined) settings.useGemini = message.payload.useGemini;
|
||||||
|
if (message.payload.geminiApiKey) settings.geminiApiKey = message.payload.geminiApiKey;
|
||||||
|
if (message.payload.geminiModel) settings.geminiModel = message.payload.geminiModel;
|
||||||
|
console.log('[ClaudeVoice] Settings updated:', settings);
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Initialize ──────────────────────────────────────────────────────────
|
||||||
|
async function init() {
|
||||||
|
await loadSettings();
|
||||||
|
injectStyles();
|
||||||
|
initSpeechIframe(); // Inject iframe immediately
|
||||||
|
createMicButton();
|
||||||
|
|
||||||
|
// Re-position on scroll and resize
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
const inputArea = findClaudeInput();
|
||||||
|
if (inputArea && micButton) {
|
||||||
|
const rect = inputArea.getBoundingClientRect();
|
||||||
|
if (rect && rect.top > 0) {
|
||||||
|
micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
const inputArea = findClaudeInput();
|
||||||
|
if (inputArea && micButton) {
|
||||||
|
const rect = inputArea.getBoundingClientRect();
|
||||||
|
if (rect && rect.top > 0) {
|
||||||
|
micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[ClaudeVoice] ✅ Arabic voice input extension loaded');
|
||||||
|
console.log('[ClaudeVoice] Language:', settings.language);
|
||||||
|
console.log('[ClaudeVoice] Gemini:', settings.useGemini ? 'Enabled' : 'Disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for page to be ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
4
claude-arabic-voice/icons/icon128.svg
Normal file
4
claude-arabic-voice/icons/icon128.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||||
|
<circle cx="64" cy="64" r="64" fill="#6c63ff"/>
|
||||||
|
<text x="64" y="90" text-anchor="middle" font-size="72" fill="white">🎤</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
4
claude-arabic-voice/icons/icon16.svg
Normal file
4
claude-arabic-voice/icons/icon16.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<circle cx="8" cy="8" r="8" fill="#6c63ff"/>
|
||||||
|
<text x="8" y="12" text-anchor="middle" font-size="10" fill="white"><EFBFBD><EFBFBD></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 222 B |
4
claude-arabic-voice/icons/icon48.svg
Normal file
4
claude-arabic-voice/icons/icon48.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||||
|
<circle cx="24" cy="24" r="24" fill="#6c63ff"/>
|
||||||
|
<text x="24" y="34" text-anchor="middle" font-size="28" fill="white">🎤</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 224 B |
56
claude-arabic-voice/manifest.json
Normal file
56
claude-arabic-voice/manifest.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "كلود - إملاء صوتي عربي",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"description": "يضيف زر مايكروفون لموقع Claude.ai للكتابة بالصوت باللغة العربية - Arabic speech-to-text for Claude.ai",
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"offscreen"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://claude.ai/*",
|
||||||
|
"https://cv.intaleqapp.com/*"
|
||||||
|
],
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://claude.ai/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"content.js"
|
||||||
|
],
|
||||||
|
"css": [
|
||||||
|
"styles.css"
|
||||||
|
],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": [
|
||||||
|
"speech.html",
|
||||||
|
"speech.js",
|
||||||
|
"permission.html",
|
||||||
|
"permission.js",
|
||||||
|
"icons/*.svg"
|
||||||
|
],
|
||||||
|
"matches": ["https://claude.ai/*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon16.svg",
|
||||||
|
"48": "icons/icon48.svg",
|
||||||
|
"128": "icons/icon128.svg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.svg",
|
||||||
|
"48": "icons/icon48.svg",
|
||||||
|
"128": "icons/icon128.svg"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
claude-arabic-voice/offscreen.html
Normal file
10
claude-arabic-voice/offscreen.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Claude Arabic Voice Offscreen</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="offscreen.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
135
claude-arabic-voice/offscreen.js
Normal file
135
claude-arabic-voice/offscreen.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// offscreen.js - Handles webkitSpeechRecognition in isolation
|
||||||
|
|
||||||
|
let recognition = null;
|
||||||
|
let isRecording = false;
|
||||||
|
|
||||||
|
// Initialize recognition early if possible, or wait until start
|
||||||
|
function initRecognition(language) {
|
||||||
|
if (recognition) return recognition;
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) {
|
||||||
|
throw new Error('Speech recognition not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
recognition = new SpeechRecognition();
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.lang = language || 'ar-SA';
|
||||||
|
recognition.maxAlternatives = 1;
|
||||||
|
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
let interimText = '';
|
||||||
|
let finalText = '';
|
||||||
|
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i];
|
||||||
|
if (result.isFinal) {
|
||||||
|
finalText += result[0].transcript + ' ';
|
||||||
|
} else {
|
||||||
|
interimText += result[0].transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send results back to background script
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'OFFSCREEN_RECORDING_RESULT',
|
||||||
|
payload: { interimText, finalText }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = (event) => {
|
||||||
|
console.error('[Offscreen] Recognition error:', event.error);
|
||||||
|
|
||||||
|
// Prevent infinite restart loop on fatal errors
|
||||||
|
if (event.error !== 'no-speech') {
|
||||||
|
isRecording = false;
|
||||||
|
if (typeof stopMediaTracks === 'function') stopMediaTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'OFFSCREEN_RECORDING_ERROR',
|
||||||
|
payload: { error: event.error }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
// If we still want to be recording, restart
|
||||||
|
if (isRecording) {
|
||||||
|
try {
|
||||||
|
recognition.start();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Offscreen] Restart failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chrome.runtime.sendMessage({ type: 'OFFSCREEN_RECORDING_END' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return recognition;
|
||||||
|
}
|
||||||
|
|
||||||
|
let localStream = null;
|
||||||
|
|
||||||
|
function stopMediaTracks() {
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach(track => track.stop());
|
||||||
|
localStream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from background script
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
if (message.type === 'START_RECORDING') {
|
||||||
|
const lang = message.payload?.language || 'ar-SA';
|
||||||
|
|
||||||
|
// 1. Get an audio stream first. This is REQUIRED in offscreen documents
|
||||||
|
// to actually activate the microphone and ensure permissions are active.
|
||||||
|
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
.then((stream) => {
|
||||||
|
localStream = stream;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!recognition || recognition.lang !== lang) {
|
||||||
|
recognition = initRecognition(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecording) {
|
||||||
|
recognition.start();
|
||||||
|
isRecording = true;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} else {
|
||||||
|
sendResponse({ success: true, warning: 'Already recording' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Offscreen] Failed to start:', e);
|
||||||
|
sendResponse({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[Offscreen] getUserMedia failed:', err);
|
||||||
|
// The user hasn't granted permission yet, or hardware error
|
||||||
|
sendResponse({ success: false, error: 'not-allowed' });
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'OFFSCREEN_RECORDING_ERROR',
|
||||||
|
payload: { error: 'not-allowed' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true; // Keep channel open for async sendResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'STOP_RECORDING') {
|
||||||
|
if (isRecording && recognition) {
|
||||||
|
isRecording = false;
|
||||||
|
try {
|
||||||
|
recognition.stop();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Offscreen] Stop failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopMediaTracks();
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
70
claude-arabic-voice/permission.html
Normal file
70
claude-arabic-voice/permission.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>السماح باستخدام الميكروفون</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background-color: #0a0a0f;
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: #1a1a26;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(108, 99, 255, 0.2);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #6c63ff;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #a0a0b8;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: linear-gradient(135deg, #6c63ff, #4834d4);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 15px rgba(108, 99, 255, 0.4);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">🎤</div>
|
||||||
|
<h1>مطلوب إذن الميكروفون</h1>
|
||||||
|
<p>ميزة الإملاء الصوتي تحتاج إلى إذن للوصول إلى الميكروفون لتعمل بشكل صحيح في الخلفية.</p>
|
||||||
|
<p>يرجى الضغط على الزر أدناه والموافقة على طلب الصلاحية من المتصفح.</p>
|
||||||
|
<button class="btn" id="grantBtn">منح صلاحية الميكروفون</button>
|
||||||
|
<p id="status" style="margin-top: 20px; font-weight: bold;"></p>
|
||||||
|
</div>
|
||||||
|
<script src="permission.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
claude-arabic-voice/permission.js
Normal file
19
claude-arabic-voice/permission.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
document.getElementById('grantBtn').addEventListener('click', async () => {
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
// Stop the tracks immediately since we only needed to ask for permission
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
status.style.color = '#00d67e';
|
||||||
|
status.textContent = '✅ تم منح الصلاحية بنجاح! يمكنك إغلاق هذه النافذة والعودة إلى Claude.';
|
||||||
|
|
||||||
|
// Auto close after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
window.close();
|
||||||
|
}, 3000);
|
||||||
|
} catch (err) {
|
||||||
|
status.style.color = '#ff4d6d';
|
||||||
|
status.textContent = '❌ حدث خطأ أو تم رفض الصلاحية: ' + err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
96
claude-arabic-voice/popup.html
Normal file
96
claude-arabic-voice/popup.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>كلود - إملاء صوتي عربي</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="popup-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="popup-header">
|
||||||
|
<div class="popup-icon">🎤</div>
|
||||||
|
<div class="popup-title">
|
||||||
|
<h1>الإملاء الصوتي لكلود</h1>
|
||||||
|
<p class="subtitle">Claude Arabic Voice Input</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="status-section">
|
||||||
|
<div class="status-indicator" id="statusIndicator">
|
||||||
|
<span class="status-dot" id="statusDot"></span>
|
||||||
|
<span id="statusText">جاهز للاستخدام</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>⚙️ الإعدادات</h2>
|
||||||
|
|
||||||
|
<!-- Language -->
|
||||||
|
<div class="setting-group">
|
||||||
|
<label for="language">لغة التعرف على الصوت</label>
|
||||||
|
<select id="language">
|
||||||
|
<option value="ar-SA">العربية (السعودية)</option>
|
||||||
|
<option value="ar-AE">العربية (الإمارات)</option>
|
||||||
|
<option value="ar-EG">العربية (مصر)</option>
|
||||||
|
<option value="ar-JO">العربية (الأردن)</option>
|
||||||
|
<option value="ar-SY">العربية (سوريا)</option>
|
||||||
|
<option value="ar-IQ">العربية (العراق)</option>
|
||||||
|
<option value="ar">العربية (عام)</option>
|
||||||
|
<option value="en-US">English (US)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto Send -->
|
||||||
|
<div class="setting-group checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="autoSend">
|
||||||
|
<span>إرسال تلقائي بعد الانتهاء من الكلام</span>
|
||||||
|
</label>
|
||||||
|
<p class="setting-hint">بعد التوقف عن الكلام لمدة ثانيتين، يتم إرسال النص تلقائياً</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gemini AI -->
|
||||||
|
<div class="setting-group divider">
|
||||||
|
<h3>🤖 معالجة النص بـ Gemini AI</h3>
|
||||||
|
<p class="setting-hint">يحسن دقة النص المنطوق ويصحح الأخطاء الإملائية</p>
|
||||||
|
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="useGemini">
|
||||||
|
<span>تفعيل معالجة Gemini</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="geminiSettings" class="gemini-settings" style="display:none;">
|
||||||
|
<label for="geminiApiKey">مفتاح API</label>
|
||||||
|
<input type="password" id="geminiApiKey" placeholder="أدخل مفتاح Gemini API">
|
||||||
|
<p class="setting-hint">استخدم نفس مفتاح API الموجود في إعدادات LinkedIn Analyzer</p>
|
||||||
|
<p class="setting-hint">النموذج المستخدم: <strong>gemini-flash-lite-latest</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- How to use -->
|
||||||
|
<div class="help-section">
|
||||||
|
<h2>📖 كيفية الاستخدام</h2>
|
||||||
|
<ol>
|
||||||
|
<li>اذهب إلى <strong>claude.ai</strong></li>
|
||||||
|
<li>ستجد زر المايك 🎤 بجانب حقل الإدخال</li>
|
||||||
|
<li>اضغط على الزر وابدأ بالتحدث بالعربية</li>
|
||||||
|
<li>عند التوقف عن الكلام، سيتم إدراج النص تلقائياً</li>
|
||||||
|
<li>يمكنك تفعيل Gemini لتحسين دقة النص</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="popup-footer">
|
||||||
|
<button id="saveBtn" class="save-btn">💾 حفظ الإعدادات</button>
|
||||||
|
<p id="saveMessage" class="save-message"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
83
claude-arabic-voice/popup.js
Normal file
83
claude-arabic-voice/popup.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// popup.js — Settings UI for Claude Arabic Voice
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// ─── Load Settings ───────────────────────────────────────────────────────
|
||||||
|
chrome.storage.sync.get(
|
||||||
|
['language', 'autoSend', 'useGemini', 'geminiApiKey', 'geminiModel'],
|
||||||
|
(data) => {
|
||||||
|
if (data.language) document.getElementById('language').value = data.language;
|
||||||
|
if (data.autoSend) document.getElementById('autoSend').checked = data.autoSend;
|
||||||
|
if (data.useGemini) {
|
||||||
|
document.getElementById('useGemini').checked = data.useGemini;
|
||||||
|
document.getElementById('geminiSettings').style.display = 'block';
|
||||||
|
}
|
||||||
|
if (data.geminiApiKey) document.getElementById('geminiApiKey').value = data.geminiApiKey;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Toggle Gemini Settings ──────────────────────────────────────────────
|
||||||
|
document.getElementById('useGemini').addEventListener('change', (e) => {
|
||||||
|
document.getElementById('geminiSettings').style.display = e.target.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Save Settings ───────────────────────────────────────────────────────
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', () => {
|
||||||
|
const settings = {
|
||||||
|
language: document.getElementById('language').value,
|
||||||
|
autoSend: document.getElementById('autoSend').checked,
|
||||||
|
useGemini: document.getElementById('useGemini').checked,
|
||||||
|
geminiApiKey: document.getElementById('geminiApiKey').value.trim(),
|
||||||
|
geminiModel: 'gemini-flash-lite-latest'
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.storage.sync.set(settings, () => {
|
||||||
|
const message = document.getElementById('saveMessage');
|
||||||
|
message.textContent = '✅ تم حفظ الإعدادات بنجاح!';
|
||||||
|
message.style.color = '#00c853';
|
||||||
|
setTimeout(() => {
|
||||||
|
message.textContent = '';
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
// Notify content script of settings change
|
||||||
|
chrome.tabs.query({ url: 'https://claude.ai/*' }, (tabs) => {
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
chrome.tabs.sendMessage(tab.id, {
|
||||||
|
type: 'SETTINGS_UPDATED',
|
||||||
|
payload: settings
|
||||||
|
}).catch(() => { /* tab may not have content script */ });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Update Status ──────────────────────────────────────────────────────
|
||||||
|
function updateStatus() {
|
||||||
|
const statusDot = document.getElementById('statusDot');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
|
chrome.tabs.query({ url: 'https://claude.ai/*' }, (tabs) => {
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
statusDot.className = 'status-dot offline';
|
||||||
|
statusText.textContent = '🔴 Claude.ai غير مفتوح';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content script is loaded
|
||||||
|
chrome.tabs.sendMessage(tabs[0].id, { type: 'GET_STATUS' }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
statusDot.className = 'status-dot offline';
|
||||||
|
statusText.textContent = '🔴 الإضافة غير نشطة - أعد تحميل الصفحة';
|
||||||
|
} else if (response && response.isListening) {
|
||||||
|
statusDot.className = 'status-dot listening';
|
||||||
|
statusText.textContent = '🔴 جارٍ الاستماع...';
|
||||||
|
} else {
|
||||||
|
statusDot.className = 'status-dot online';
|
||||||
|
statusText.textContent = '✅ جاهز للاستخدام';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus();
|
||||||
|
setInterval(updateStatus, 3000);
|
||||||
|
});
|
||||||
10
claude-arabic-voice/speech.html
Normal file
10
claude-arabic-voice/speech.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Speech Recognition Frame</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="speech.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
claude-arabic-voice/speech.js
Normal file
121
claude-arabic-voice/speech.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// speech.js - Handles webkitSpeechRecognition inside the iframe
|
||||||
|
|
||||||
|
let recognition = null;
|
||||||
|
let isRecording = false;
|
||||||
|
let localStream = null;
|
||||||
|
|
||||||
|
function stopMediaTracks() {
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach(track => track.stop());
|
||||||
|
localStream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRecognition(language) {
|
||||||
|
if (recognition) return recognition;
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) {
|
||||||
|
throw new Error('Speech recognition not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
recognition = new SpeechRecognition();
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.lang = language || 'ar-SA';
|
||||||
|
recognition.maxAlternatives = 1;
|
||||||
|
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
let interimText = '';
|
||||||
|
let finalText = '';
|
||||||
|
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i];
|
||||||
|
if (result.isFinal) {
|
||||||
|
finalText += result[0].transcript + ' ';
|
||||||
|
} else {
|
||||||
|
interimText += result[0].transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'SPEECH_RESULT',
|
||||||
|
payload: { interimText, finalText }
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = (event) => {
|
||||||
|
console.error('[Speech Iframe] Recognition error:', event.error);
|
||||||
|
|
||||||
|
if (event.error !== 'no-speech') {
|
||||||
|
isRecording = false;
|
||||||
|
stopMediaTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'SPEECH_ERROR',
|
||||||
|
payload: { error: event.error }
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (isRecording) {
|
||||||
|
try {
|
||||||
|
recognition.start();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Speech Iframe] Restart failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopMediaTracks();
|
||||||
|
window.parent.postMessage({ type: 'SPEECH_END' }, '*');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return recognition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from parent window
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
// Basic security check (only accept messages from the parent Claude page)
|
||||||
|
if (!event.origin.includes('claude.ai')) return;
|
||||||
|
|
||||||
|
const message = event.data;
|
||||||
|
|
||||||
|
if (message.type === 'START_RECORDING') {
|
||||||
|
const lang = message.payload?.language || 'ar-SA';
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
.then((stream) => {
|
||||||
|
localStream = stream;
|
||||||
|
try {
|
||||||
|
if (!recognition || recognition.lang !== lang) {
|
||||||
|
recognition = initRecognition(lang);
|
||||||
|
}
|
||||||
|
if (!isRecording) {
|
||||||
|
recognition.start();
|
||||||
|
isRecording = true;
|
||||||
|
window.parent.postMessage({ type: 'SPEECH_START_SUCCESS' }, '*');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Speech Iframe] Failed to start:', e);
|
||||||
|
window.parent.postMessage({ type: 'SPEECH_ERROR', payload: { error: e.message } }, '*');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[Speech Iframe] getUserMedia failed:', err);
|
||||||
|
window.parent.postMessage({ type: 'SPEECH_ERROR', payload: { error: 'not-allowed' } }, '*');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'STOP_RECORDING') {
|
||||||
|
if (isRecording && recognition) {
|
||||||
|
isRecording = false;
|
||||||
|
try {
|
||||||
|
recognition.stop();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Speech Iframe] Stop failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopMediaTracks();
|
||||||
|
}
|
||||||
|
});
|
||||||
290
claude-arabic-voice/styles.css
Normal file
290
claude-arabic-voice/styles.css
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/* ─── Popup Styles ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
width: 380px;
|
||||||
|
min-height: 400px;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Header ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #6c63ff, #4834d4);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title .subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin: 2px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Status ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.status-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
background: #00c853;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 200, 83, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.offline {
|
||||||
|
background: #ff5252;
|
||||||
|
box-shadow: 0 0 8px rgba(255, 82, 82, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.listening {
|
||||||
|
background: #ff1744;
|
||||||
|
animation: pulse-dot 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 23, 68, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(255, 23, 68, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 23, 68, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Settings ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #bbb;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group select,
|
||||||
|
.setting-group input[type="password"],
|
||||||
|
.setting-group input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group select:focus,
|
||||||
|
.setting-group input:focus {
|
||||||
|
border-color: #6c63ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #777;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint a {
|
||||||
|
color: #6c63ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Checkbox ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: #ddd !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #6c63ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Divider ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Gemini Settings ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.gemini-settings {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(108, 99, 255, 0.08);
|
||||||
|
border: 1px solid rgba(108, 99, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gemini-settings label {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gemini-settings label:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Help Section ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section ol {
|
||||||
|
padding-right: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section ol li strong {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Footer ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.popup-footer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: linear-gradient(135deg, #6c63ff, #4834d4);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 15px rgba(108, 99, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-message {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
236
content.js
236
content.js
@@ -230,16 +230,10 @@
|
|||||||
const questions = [];
|
const questions = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
// Find the Easy Apply modal using real LinkedIn selectors
|
|
||||||
const modal = document.querySelector(
|
|
||||||
'[role="dialog"].jobs-easy-apply-modal, .artdeco-modal.jobs-easy-apply-modal, ' +
|
|
||||||
'.artdeco-modal, [role="dialog"], #artdeco-modal-outlet'
|
|
||||||
);
|
|
||||||
const searchRoot = modal || document.body;
|
|
||||||
|
|
||||||
// LinkedIn wraps each form field in .fb-dash-form-element or [data-test-form-element]
|
// LinkedIn wraps each form field in .fb-dash-form-element or [data-test-form-element]
|
||||||
const formElements = searchRoot.querySelectorAll(
|
// Search the whole document to avoid getting trapped in the wrong dialog (like the messaging pane).
|
||||||
'.fb-dash-form-element, [data-test-form-element]'
|
const formElements = document.querySelectorAll(
|
||||||
|
'.jobs-easy-apply-modal .fb-dash-form-element, .jobs-easy-apply-modal [data-test-form-element], .fb-dash-form-element, [data-test-form-element]'
|
||||||
);
|
);
|
||||||
|
|
||||||
formElements.forEach(el => {
|
formElements.forEach(el => {
|
||||||
@@ -268,25 +262,28 @@
|
|||||||
questions.push({ question: text, type: inputType });
|
questions.push({ question: text, type: inputType });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fallback: if the precise approach found nothing, scan broader
|
// Fallback: if the precise approach found nothing, scan broader inside modals
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
const allLabels = searchRoot.querySelectorAll('label');
|
const activeModals = document.querySelectorAll('.jobs-easy-apply-modal, .artdeco-modal, [role="dialog"]');
|
||||||
allLabels.forEach(label => {
|
activeModals.forEach(modal => {
|
||||||
const ariaSpan = label.querySelector('span[aria-hidden="true"]');
|
const allLabels = modal.querySelectorAll('label');
|
||||||
let text = (ariaSpan ? ariaSpan.textContent : label.textContent)
|
allLabels.forEach(label => {
|
||||||
.replace(/\*/g, '').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
const ariaSpan = label.querySelector('span[aria-hidden="true"]');
|
||||||
|
let text = (ariaSpan ? ariaSpan.textContent : label.textContent)
|
||||||
if (!text || text.length < 8 || text.length > 300 || seen.has(text.toLowerCase())) return;
|
.replace(/\*/g, '').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
const looksLikeQuestion = (
|
if (!text || text.length < 8 || text.length > 300 || seen.has(text.toLowerCase())) return;
|
||||||
text.endsWith('?') ||
|
|
||||||
/^(how many|what|do you|are you|have you|will you|describe|tell us)/i.test(text) ||
|
const looksLikeQuestion = (
|
||||||
/salary|experience|education|degree|visa|relocat|notice|certif/i.test(text)
|
text.endsWith('?') ||
|
||||||
);
|
/^(how many|what|do you|are you|have you|will you|describe|tell us)/i.test(text) ||
|
||||||
if (!looksLikeQuestion) return;
|
/salary|experience|education|degree|visa|relocat|notice|certif/i.test(text)
|
||||||
|
);
|
||||||
|
if (!looksLikeQuestion) return;
|
||||||
|
|
||||||
seen.add(text.toLowerCase());
|
seen.add(text.toLowerCase());
|
||||||
questions.push({ question: text, type: 'text' });
|
questions.push({ question: text, type: 'text' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,7 +535,8 @@
|
|||||||
apiKey: settings.apiKey,
|
apiKey: settings.apiKey,
|
||||||
jobDescription: jobData.description,
|
jobDescription: jobData.description,
|
||||||
jobTitle: jobData.jobTitle || 'Job',
|
jobTitle: jobData.jobTitle || 'Job',
|
||||||
action: 'generatePdf'
|
action: 'generatePdf',
|
||||||
|
template: 'amman'
|
||||||
}
|
}
|
||||||
}, resolve);
|
}, resolve);
|
||||||
});
|
});
|
||||||
@@ -641,6 +639,17 @@
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Attach inline copy button listeners
|
||||||
|
if (tab === 'qa') {
|
||||||
|
pane.querySelectorAll('.lja-qa-copy-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
navigator.clipboard.writeText(this.getAttribute('data-answer'));
|
||||||
|
this.textContent = '✅';
|
||||||
|
setTimeout(() => this.textContent = '📋', 1200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
copyBtn.style.display = 'block';
|
copyBtn.style.display = 'block';
|
||||||
|
|
||||||
// Show auto-fill button for Q&A tab
|
// Show auto-fill button for Q&A tab
|
||||||
@@ -813,12 +822,7 @@ Brief honest assessment of this opportunity for my profile`
|
|||||||
<div style="font-weight: 600; font-size: 12px; color: #aaa; margin-bottom: 4px;">❓ ${q}</div>
|
<div style="font-weight: 600; font-size: 12px; color: #aaa; margin-bottom: 4px;">❓ ${q}</div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<div style="color: #4caf50; font-size: 14px; font-weight: 500; flex: 1;" id="lja-qa-answer-${idx}">💡 ${a}</div>
|
<div style="color: #4caf50; font-size: 14px; font-weight: 500; flex: 1;" id="lja-qa-answer-${idx}">💡 ${a}</div>
|
||||||
<button onclick="
|
<button class="lja-qa-copy-btn" data-answer="${safeAnswer}" style="background: rgba(108,99,255,0.2); border: none; border-radius: 4px; padding: 4px 8px; cursor: pointer; color: #b0b0ff; font-size: 14px; flex-shrink: 0;" title="Copy answer">📋</button>
|
||||||
const text = document.getElementById('lja-qa-answer-${idx}').textContent.replace('💡 ', '');
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
this.textContent = '✅';
|
|
||||||
setTimeout(() => this.textContent = '📋', 1200);
|
|
||||||
" style="background: rgba(108,99,255,0.2); border: none; border-radius: 4px; padding: 4px 8px; cursor: pointer; color: #b0b0ff; font-size: 14px; flex-shrink: 0;" title="Copy answer">📋</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
@@ -931,7 +935,16 @@ Brief honest assessment of this opportunity for my profile`
|
|||||||
|
|
||||||
function getSettings() {
|
function getSettings() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
chrome.storage.sync.get(['apiKey', 'userProfile', 'language'], resolve);
|
chrome.storage.sync.get(['apiKey', 'userProfile', 'language'], (data) => {
|
||||||
|
if (data.userProfile && (data.userProfile.includes('hamzaayed@intaleqapp.com') || data.userProfile.includes('hamzaayedflutter@gmail.com') || data.userProfile.includes('hamzaayed@tripz-egypt.com'))) {
|
||||||
|
data.userProfile = data.userProfile
|
||||||
|
.replace(/hamzaayed@intaleqapp\.com/g, 'hamzaayed.dev@gmail.com')
|
||||||
|
.replace(/hamzaayedflutter@gmail\.com/g, 'hamzaayed.dev@gmail.com')
|
||||||
|
.replace(/hamzaayed@tripz-egypt\.com/g, 'hamzaayed.dev@gmail.com');
|
||||||
|
chrome.storage.sync.set({ userProfile: data.userProfile });
|
||||||
|
}
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,6 +960,155 @@ Brief honest assessment of this opportunity for my profile`
|
|||||||
setTimeout(() => toast.classList.remove('show'), 2500);
|
setTimeout(() => toast.classList.remove('show'), 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── List Scanner ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function injectListScanner() {
|
||||||
|
if (document.getElementById('lja-scan-btn')) return;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'lja-scan-btn';
|
||||||
|
btn.innerHTML = '🔍 Scan List';
|
||||||
|
btn.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
z-index: 999999;
|
||||||
|
background: linear-gradient(135deg, #00d67e, #00a65e);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 214, 126, 0.4);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
`;
|
||||||
|
btn.onmouseover = () => btn.style.transform = 'scale(1.05)';
|
||||||
|
btn.onmouseout = () => btn.style.transform = 'scale(1)';
|
||||||
|
|
||||||
|
btn.onclick = async () => {
|
||||||
|
btn.innerHTML = '⏳ Scanning...';
|
||||||
|
try {
|
||||||
|
await scanJobList();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Scan failed: ' + e.message);
|
||||||
|
}
|
||||||
|
btn.innerHTML = '🔍 Scan List';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanJobList() {
|
||||||
|
const listItems = document.querySelectorAll('.jobs-search-results__list-item, .job-card-container');
|
||||||
|
if (!listItems.length) {
|
||||||
|
alert('No job items found on this page. Scroll down to load them.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobsToScan = [];
|
||||||
|
listItems.forEach((item, index) => {
|
||||||
|
const titleEl = item.querySelector('.job-card-list__title, .artdeco-entity-lockup__title');
|
||||||
|
const companyEl = item.querySelector('.job-card-container__primary-description, .artdeco-entity-lockup__subtitle');
|
||||||
|
if (titleEl && companyEl) {
|
||||||
|
jobsToScan.push({
|
||||||
|
index: index,
|
||||||
|
title: titleEl.textContent.trim().replace(/\n/g, ''),
|
||||||
|
company: companyEl.textContent.trim().replace(/\n/g, ''),
|
||||||
|
element: item
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!jobsToScan.length) {
|
||||||
|
alert('Could not parse job titles.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lja-badge').forEach(b => b.remove());
|
||||||
|
|
||||||
|
if (jobsToScan.length === 0) return;
|
||||||
|
|
||||||
|
// Limit to 25 jobs per scan to prevent AI token truncation
|
||||||
|
const jobsToProcess = jobsToScan.slice(0, 25);
|
||||||
|
const listDataStr = JSON.stringify(jobsToProcess.map(j => ({ index: j.index, title: j.title, company: j.company })));
|
||||||
|
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings.apiKey) {
|
||||||
|
alert('Please set your API key in the extension popup first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof buildPromptV2 !== 'function') {
|
||||||
|
alert('buildPromptV2 not found. Extension error.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptStr = buildPromptV2('list_analysis', { skills: [], listData: listDataStr }, settings.userProfile, settings.language);
|
||||||
|
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'GEMINI_REQUEST',
|
||||||
|
payload: { apiKey: settings.apiKey, prompt: promptStr, tab: 'list_analysis' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[LJA] Background Error:', response.error);
|
||||||
|
throw new Error('API Error: ' + response.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resultText = response.data.text;
|
||||||
|
resultText = resultText.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||||||
|
|
||||||
|
let results;
|
||||||
|
try {
|
||||||
|
const startIdx = resultText.indexOf('[');
|
||||||
|
const endIdx = resultText.lastIndexOf(']');
|
||||||
|
if (startIdx !== -1 && endIdx !== -1) {
|
||||||
|
results = JSON.parse(resultText.substring(startIdx, endIdx + 1));
|
||||||
|
} else {
|
||||||
|
results = JSON.parse(resultText);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error("AI JSON Parse Error. Raw Response:", resultText);
|
||||||
|
throw new Error('JSON Error: ' + resultText.substring(0, 80));
|
||||||
|
}
|
||||||
|
|
||||||
|
results.forEach(res => {
|
||||||
|
const jobItem = jobsToScan.find(j => j.index === res.index);
|
||||||
|
if (jobItem) {
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'lja-badge';
|
||||||
|
badge.style.cssText = `
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: white;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`;
|
||||||
|
if (res.verdict === 'YES') {
|
||||||
|
badge.style.background = 'linear-gradient(135deg, #00d67e, #00a65e)';
|
||||||
|
badge.innerHTML = `✅ MATCH: ${res.reason}`;
|
||||||
|
} else if (res.verdict === 'NO') {
|
||||||
|
badge.style.background = 'linear-gradient(135deg, #ff4d6d, #d90429)';
|
||||||
|
badge.innerHTML = `❌ SKIP: ${res.reason}`;
|
||||||
|
} else {
|
||||||
|
badge.style.background = 'linear-gradient(135deg, #ffb347, #ff9200)';
|
||||||
|
badge.innerHTML = `⚠️ MAYBE: ${res.reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentContainer = jobItem.element.querySelector('.artdeco-entity-lockup__content');
|
||||||
|
if (contentContainer) contentContainer.appendChild(badge);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── SPA Navigation Observer ─────────────────────────────────────────────
|
// ─── SPA Navigation Observer ─────────────────────────────────────────────
|
||||||
|
|
||||||
let lastUrl = location.href;
|
let lastUrl = location.href;
|
||||||
@@ -962,7 +1124,7 @@ Brief honest assessment of this opportunity for my profile`
|
|||||||
const old = document.getElementById('lja-root');
|
const old = document.getElementById('lja-root');
|
||||||
if (old) old.remove();
|
if (old) old.remove();
|
||||||
window.__linkedinAnalyzerLoaded = false;
|
window.__linkedinAnalyzerLoaded = false;
|
||||||
setTimeout(injectOverlay, 1200);
|
setTimeout(() => { injectOverlay(); injectListScanner(); }, 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for URL changes (SPA navigation + job switches)
|
// Watch for URL changes (SPA navigation + job switches)
|
||||||
@@ -998,9 +1160,9 @@ Brief honest assessment of this opportunity for my profile`
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', () => setTimeout(injectOverlay, 1000));
|
document.addEventListener('DOMContentLoaded', () => setTimeout(() => { injectOverlay(); injectListScanner(); }, 1000));
|
||||||
} else {
|
} else {
|
||||||
setTimeout(injectOverlay, 1000);
|
setTimeout(() => { injectOverlay(); injectListScanner(); }, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
192
cv_market_analysis.md
Normal file
192
cv_market_analysis.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# تحليل سوق عمّان — استراتيجية الـ CV
|
||||||
|
|
||||||
|
## نظرة عامة على السوق
|
||||||
|
|
||||||
|
### شركات التكنولوجيا المحلية في عمّان (Target Companies)
|
||||||
|
|
||||||
|
| الشركة | المجال | الـ Stack الشائع | المسمى الوظيفي المناسب |
|
||||||
|
|--------|--------|-----------------|----------------------|
|
||||||
|
| Mawdoo3 | محتوى/AI | PHP, Python, Node.js, AWS | Senior Backend Engineer / Tech Lead |
|
||||||
|
| Abwaab | EdTech | Node.js, React, PostgreSQL, AWS | Lead Backend Engineer |
|
||||||
|
| OpenSooq | Classifieds | PHP, MySQL, Redis, AWS | Senior Backend Developer |
|
||||||
|
| ArabiaWeather | GIS/Data | Python, GIS, APIs | GIS/Backend Developer |
|
||||||
|
| Liwwa | FinTech | Node.js, Python, PostgreSQL | Senior Backend Engineer |
|
||||||
|
| JoPACC | FinTech/Payments | Java, Node.js, APIs | Systems Engineer |
|
||||||
|
| ZenHR | HR Tech/SaaS | PHP, Node.js, MySQL | Senior Software Engineer |
|
||||||
|
| Atypon | Publishing | Java, Node.js, AWS | Senior Engineer |
|
||||||
|
|
||||||
|
### ماذا تبحث عنه شركات عمّان في الـ CV؟
|
||||||
|
|
||||||
|
1. **التسليم والتنفيذ (Delivery over Design):** يريدون شخص يبني وينفذ، مش بس يرسم architecture diagrams
|
||||||
|
2. **الاستمرارية (Retention):** الخوف الأكبر: "هل حيتركنا ويروح عالخليج؟"
|
||||||
|
3. **العمل الجماعي (Team Player):** يريدون شخص يشتغل مع فريق، يمنتور junior developers
|
||||||
|
4. **التكلفة (Cost-Conscious):** يحبوا أرقام التوفير — $10K/month قصة قوية
|
||||||
|
5. **اللغة (Bilingual):** ثنائية اللغة ميزة تنافسية كبيرة
|
||||||
|
6. **السوق المحلي (Local Market):** فهم السوق الأردني والعربي مهم
|
||||||
|
7. **الـ Stack المطلوب:** PHP, Node.js, PostgreSQL, AWS, Docker — مش GIS ولا ride-hailing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## المشكلة الأساسية مع الـ CV الحالي
|
||||||
|
|
||||||
|
### ١. العنوان والـ Positioning
|
||||||
|
|
||||||
|
**الحالي:** "Solutions Architect" — هذا عنوان شركات enterprise في الخليج وأوروبا. شركة عمّان ما بتوظف Solutions Architect. بتوظف Senior Backend Engineer أو Tech Lead.
|
||||||
|
|
||||||
|
**المطلوب:** استبدال كل mentions الـ "Solutions Architect" بـ: `Senior Backend Engineer & Technical Lead` أو `Lead Backend Engineer — Distributed Systems` حسب الوظيفة.
|
||||||
|
|
||||||
|
### ٢. قصة الـ Founder/CTO
|
||||||
|
|
||||||
|
**المشكلة:** الـ CV الحالي بيحكي قصة "أنا بنيت شركتين" مش "أنا مهندس ممكن أضيف قيمة لشركتكم."
|
||||||
|
|
||||||
|
**الحل:** إعادة صياغة التجربة كلغة delivery و engineering، مش founding:
|
||||||
|
- ❌ "CTO & Technical Architect — Intaleq"
|
||||||
|
- ✅ "Technical Lead & Backend Engineer — Intaleq"
|
||||||
|
|
||||||
|
- ❌ "Founding Engineer & Lead Backend Architect — Tripz Egypt"
|
||||||
|
- ✅ "Lead Backend Engineer — Tripz Egypt"
|
||||||
|
|
||||||
|
- ❌ "Co-founded and architected..."
|
||||||
|
- ✅ "Built and scaled..."
|
||||||
|
|
||||||
|
### ٣. الـ AI Claim
|
||||||
|
|
||||||
|
**الحالي:** عنوان "AI Solutions Architect" مع إن الـ AI experience محدود بـ LLM API integration.
|
||||||
|
|
||||||
|
**الحل:**
|
||||||
|
- حذف "AI" من الـ positioning الأساسي
|
||||||
|
- ذكر الـ AI tools كمهارة إضافية، مش كتخصص
|
||||||
|
- إعادة تسمية: Nabih → "Automated Customer Support System" مش "AI Smart Responder"
|
||||||
|
- إعادة تسمية: Musadaq → "Document Processing Platform" مش "AI-Powered Invoice Processing"
|
||||||
|
|
||||||
|
### ٤. الـ Stack Prioritization
|
||||||
|
|
||||||
|
**الحالي:** OSM, GraphHopper, MapLibre, PostGIS في المقدمة — هذا stack شخص بنى ride-hailing.
|
||||||
|
|
||||||
|
**المطلوب لعمّان:**
|
||||||
|
1. PHP (Workerman), Node.js, NestJS — أول شيء
|
||||||
|
2. PostgreSQL, MySQL, Redis
|
||||||
|
3. Docker, AWS, CI/CD
|
||||||
|
4. REST APIs, WebSockets, Microservices
|
||||||
|
5. Flutter (ثانوي)
|
||||||
|
6. GIS (في الأخير — ميزة إضافية مش أساسية)
|
||||||
|
|
||||||
|
### ٥. السطر الـ AI-generated
|
||||||
|
|
||||||
|
**الحذف فوراً:**
|
||||||
|
> *"I leverage my expertise..."*
|
||||||
|
|
||||||
|
أي HR في عمّان شايف هاد السطر ١٠٠ مرة من ناس مستخدمين ChatGPT.
|
||||||
|
|
||||||
|
### ٦. الفريلانس بدون أسماء
|
||||||
|
|
||||||
|
**المشكلة:** "25+ production applications" بدون أسماء شركات أو صناعات.
|
||||||
|
|
||||||
|
**الحل:** ذكر الصناعات بوضوح — healthcare, logistics, HR, sports, utilities, fintech — بدون أسماء شركات إذا ما في إذن، لكن الصناعات تبني ثقة.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الـ CV الجديد — الهيكل المقترح لسوق عمّان
|
||||||
|
|
||||||
|
### Header
|
||||||
|
- الاسم: Hamza Ayed
|
||||||
|
- العنوان: `{{JOB_HEADLINE}}` — يجب أن يكون `Senior Backend Engineer & Technical Lead` أو ما يناسب الوظيفة
|
||||||
|
- التواصل: عمّان، الأردن (مش Amman, Jordan — أكد الموقع)
|
||||||
|
|
||||||
|
### ١. Professional Summary
|
||||||
|
- التركيز على: delivery، scale، cost savings، MENA experience
|
||||||
|
- حذف ride-hailing hook الإجباري — استبداله بـ hook عن building production systems
|
||||||
|
- 3-4 أسطر: من أنا، ماذا بنيت، ماذا أقدم للشركة
|
||||||
|
|
||||||
|
### ٢. Technical Skills (مرتبة حسب أولوية سوق عمّان)
|
||||||
|
```
|
||||||
|
Core Focus: {{DYNAMIC_SKILLS}} ← AI-generated per job
|
||||||
|
|
||||||
|
Backend & Architecture: PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), REST APIs, WebSockets, Microservices
|
||||||
|
Infrastructure & Cloud: Docker, Linux, Nginx, Redis, CI/CD (GitHub Actions), AWS
|
||||||
|
Databases: PostgreSQL, MySQL, Redis, Database Design & Query Optimization
|
||||||
|
Geospatial (Supplemental): PostGIS, OpenStreetMap, GraphHopper, MapLibre GL
|
||||||
|
Mobile: Flutter/Dart, GetX, BLoC/Cubit
|
||||||
|
```
|
||||||
|
|
||||||
|
### ٣. Technical Impact (جديد — يركز على delivery والإنجاز)
|
||||||
|
- Built platforms serving 6,000+ active users across two markets
|
||||||
|
- Engineered proprietary mapping infrastructure replacing Google Maps — $10K+/month savings
|
||||||
|
- Designed high-concurrency WebSocket systems for real-time operations
|
||||||
|
- Delivered 25+ production applications across healthcare, logistics, fintech, and utilities
|
||||||
|
- Published open-source SDKs on pub.dev and npm
|
||||||
|
|
||||||
|
### ٤. Professional Experience (معاد صياغته)
|
||||||
|
```
|
||||||
|
Technical Lead & Backend Engineer — Intaleq | Jan 2024–Present | Amman, Jordan
|
||||||
|
• Built full-stack transportation platform from zero: PHP/Workerman backend, Flutter apps, WebSocket dispatcher — serving 1,800+ drivers
|
||||||
|
• Developed proprietary mapping platform (NestJS, GraphHopper, OSM, PostGIS) — $10K+/month cost reduction vs Google Maps
|
||||||
|
• Designed event-driven architecture for real-time matching and tracking
|
||||||
|
|
||||||
|
Lead Backend Engineer — Tripz Egypt | Jan 2023–Present | Cairo / Remote
|
||||||
|
• Built complete ride-hailing system: PHP backend, Flutter apps, real-time WebSocket layer, payment integrations
|
||||||
|
• Engineered driver/rider matching system serving 4,318 drivers — $0.78 customer acquisition cost
|
||||||
|
• Integrated local payment gateways and automated payout infrastructure
|
||||||
|
|
||||||
|
Senior Backend Engineer — Freelance | Jan 2017–Dec 2023 | Jordan / Remote
|
||||||
|
• Delivered 25+ applications for MENA clients: healthcare platforms, logistics systems, HR tools, sports apps, utility dashboards
|
||||||
|
• Built automated customer support platform (Flutter + PHP backend)
|
||||||
|
• Developed document processing system using vision models and async pipelines
|
||||||
|
```
|
||||||
|
|
||||||
|
### ٥. Notable Projects
|
||||||
|
- IntaleqMaps Engine (NestJS, GraphHopper, OSM, PostGIS) — proprietary mapping
|
||||||
|
- Tripz Egypt Platform — end-to-end ride-hailing
|
||||||
|
- Automated Customer Support Platform — Flutter + high-concurrency PHP
|
||||||
|
- Document Processing System — AI vision models + async pipelines
|
||||||
|
- Meta Ads Manager — multi-tenant SaaS (NestJS/React)
|
||||||
|
- WhatsApp Bridge — high-concurrency Node.js + headless Puppeteer
|
||||||
|
|
||||||
|
### ٦. Open Source (كما هو)
|
||||||
|
### ٧. Education (كما هو)
|
||||||
|
### ٨. Languages (كما هو)
|
||||||
|
|
||||||
|
### ٩. Availability (معدل لسوق عمّان)
|
||||||
|
```
|
||||||
|
Based in Amman, Jordan — Available Immediately
|
||||||
|
Open to On-site, Hybrid, and Remote roles in Jordan and MENA
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تغييرات الـ Prompt المطلوبة
|
||||||
|
|
||||||
|
### Current Prompt (generatePdf):
|
||||||
|
```
|
||||||
|
My title MUST be aligned with 'Solutions Architect', 'Senior Backend Engineer', or 'Senior Mobile Engineer'.
|
||||||
|
The summary MUST open with exactly this hook: 'Built two production ride-hailing platforms from zero to thousands of users, on proprietary infrastructure, in high-complexity and emerging markets.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Prompt (Amman market):
|
||||||
|
```
|
||||||
|
My title MUST be aligned with 'Senior Backend Engineer', 'Technical Lead', or 'Lead Backend Engineer'.
|
||||||
|
NEVER use 'Solutions Architect' or 'AI Developer'.
|
||||||
|
The summary should open with a hook about building and scaling production systems in the MENA region.
|
||||||
|
Focus on delivery, cost optimization, and hands-on engineering — not enterprise architecture.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ملخص التغييرات على مستوى الملفات
|
||||||
|
|
||||||
|
| الملف | التغيير |
|
||||||
|
|-------|---------|
|
||||||
|
| `server/cv_template_amman.html` | **جديد** — قالب مخصص لسوق عمّان |
|
||||||
|
| `server/cv_template.html` | يبقى كما هو (للخليج/enterprise) |
|
||||||
|
| `server/generate_cv.php` | إضافة `template` parameter + prompt مخصص |
|
||||||
|
| `prompts.js` | تحديث `cvtips` ليعكس positioning سوق عمّان |
|
||||||
|
| `linkedin-analyzer/cv_template.html` | مزامنة مع الـ server template |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الخطوات التالية
|
||||||
|
|
||||||
|
1. إنشاء `server/cv_template_amman.html`
|
||||||
|
2. تعديل `generate_cv.php` لدعم template selection
|
||||||
|
3. تعديل `prompts.js` — تحديث cvtips لسوق عمّان
|
||||||
|
4. للمستقبل: إنشاء templates إضافية لـ UAE enterprise و FinTech
|
||||||
131
cv_template.html
131
cv_template.html
@@ -8,19 +8,23 @@
|
|||||||
* Avoids flexbox, uses standard block/inline styling for flawless PDF generation.
|
* Avoids flexbox, uses standard block/inline styling for flawless PDF generation.
|
||||||
*/
|
*/
|
||||||
body {
|
body {
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings: "liga" 0, "clig" 0;
|
||||||
|
letter-spacing: 0px;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #222;
|
color: #222;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px 40px;
|
padding: 15px 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 2px solid #1a237e;
|
border-bottom: 2px solid #1a237e;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -48,40 +52,48 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #1a237e;
|
color: #1a237e;
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
margin-top: 20px;
|
margin-top: 15px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 6px;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p { margin: 0 0 10px 0; text-align: justify; }
|
p { margin: 0 0 6px 0; text-align: justify; }
|
||||||
|
|
||||||
.job-block { margin-bottom: 15px; }
|
.job-block { margin-bottom: 10px; }
|
||||||
|
|
||||||
.job-header {
|
.job-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
display: table; /* هذا يحل مشاكل Dompdf */
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-title {
|
.job-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 14px;
|
font-size: 13.5px; /* تصغير طفيف جداً */
|
||||||
color: #222;
|
color: #222;
|
||||||
float: left;
|
float: left;
|
||||||
|
width: 70%; /* إجبار العنوان على عدم تجاوز 70% من السطر */
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-meta {
|
.job-meta {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #1a237e;
|
color: #1a237e;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
float: right;
|
float: right;
|
||||||
|
width: 28%; /* تخصيص مساحة آمنة للتاريخ */
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear { clear: both; }
|
.clear { clear: both; }
|
||||||
|
|
||||||
ul { margin: 5px 0 10px 0; padding-left: 20px; }
|
|
||||||
li { margin-bottom: 6px; text-align: justify; }
|
|
||||||
|
|
||||||
|
ul { margin: 4px 0 8px 0; padding-left: 20px; }
|
||||||
|
li { margin-bottom: 4px; text-align: justify; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -91,7 +103,10 @@
|
|||||||
<!-- PHP WILL INJECT THE AI-GENERATED HEADLINE HERE -->
|
<!-- PHP WILL INJECT THE AI-GENERATED HEADLINE HERE -->
|
||||||
<div class="headline">{{JOB_HEADLINE}}</div>
|
<div class="headline">{{JOB_HEADLINE}}</div>
|
||||||
<div class="contact">
|
<div class="contact">
|
||||||
Amman, Jordan | +962 79 858 3052 | hamzaayed@intaleqapp.com | linkedin.com/in/hamza-ayed
|
Amman, Jordan | +962 79 858 3052 | <a href="mailto:hamzaayed.dev@gmail.com" style="color: #555; text-decoration: none;">hamzaayed.dev@gmail.com</a><br>
|
||||||
|
<a href="https://linkedin.com/in/hamza-ayed" style="color: #555; text-decoration: none; font-weight: bold;">linkedin.com/in/hamza-ayed</a> |
|
||||||
|
<a href="https://github.com/Hamza-Ayed" style="color: #555; text-decoration: none; font-weight: bold;">github.com/Hamza-Ayed</a> |
|
||||||
|
<a href="https://intaleqapp.com/hamza.html" style="color: #1a237e; text-decoration: underline; font-weight: bold;">intaleqapp.com/hamza.html (Portfolio)</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,71 +114,95 @@
|
|||||||
<!-- PHP WILL INJECT THE AI-GENERATED SUMMARY HERE -->
|
<!-- PHP WILL INJECT THE AI-GENERATED SUMMARY HERE -->
|
||||||
<p>{{TAILORED_SUMMARY}}</p>
|
<p>{{TAILORED_SUMMARY}}</p>
|
||||||
|
|
||||||
<div class="section-title">Core Competencies & Skills</div>
|
|
||||||
|
|
||||||
|
<div class="section-title">Technical Skills</div>
|
||||||
<!-- PHP WILL INJECT ATS KEYWORDS HERE -->
|
<!-- PHP WILL INJECT ATS KEYWORDS HERE -->
|
||||||
<p><strong>Targeted Expertise:</strong> {{DYNAMIC_SKILLS}}</p>
|
<p><strong>Core Focus:</strong> {{DYNAMIC_SKILLS}}</p>
|
||||||
<p><strong>Technologies:</strong> Flutter/Dart, PHP, Python (FastAPI/Flask), Node.js, MySQL, PostgreSQL, AWS/Cloud Infrastructure, Git, Docker.</p>
|
<p><strong>Backend & Architecture:</strong> PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), REST APIs, WebSockets, Event-Driven Architecture</p>
|
||||||
<p><strong>Domains:</strong> System Architecture, Distributed Systems, GIS/Mapping (OSM), FinTech (Payment Gateways), AI/ML Integration, Zero-Trust Security.</p>
|
<p><strong>Infrastructure & Cloud:</strong> Docker, Linux, Nginx, Redis, CI/CD (GitHub Actions), AWS</p>
|
||||||
|
<p><strong>Databases & Geospatial:</strong> PostgreSQL/PostGIS, MySQL, OpenStreetMap (OSM), GraphHopper, MapLibre GL, Spatial Queries</p>
|
||||||
|
<p><strong>Mobile:</strong> Flutter/Dart, GetX, BLoC/Cubit</p>
|
||||||
|
|
||||||
|
<div class="section-title">Selected Technical Highlights</div>
|
||||||
|
<ul>
|
||||||
|
<li>Built proprietary OSM-based mapping infrastructure replacing Google Maps APIs</li>
|
||||||
|
<li>Engineered WebSocket-based real-time ride tracking systems</li>
|
||||||
|
<li>Developed offline-first mapping SDKs published on pub.dev and npm</li>
|
||||||
|
<li>Designed async AI document-processing pipelines using vision models</li>
|
||||||
|
<li>Optimized routing infrastructure using GraphHopper and PostGIS</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="section-title">Professional Experience</div>
|
<div class="section-title">Professional Experience</div>
|
||||||
|
|
||||||
<div class="job-block">
|
<div class="job-block">
|
||||||
<div class="job-header">
|
<div class="job-header">
|
||||||
<div class="job-title">CTO & Solutions Architect — Intaleq</div>
|
<div class="job-title">CTO & Technical Architect | Systems & Backend Focus — Intaleq | </div>
|
||||||
<div class="job-meta">Jan 2025 – Present | Syria / Remote</div>
|
<div class="job-meta">January 2024 – Present | Remote (MENA Region)</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Led the full-stack architecture of a smart transportation ecosystem supporting 1,800+ drivers and 2,500+ riders.</li>
|
<li>Led the full-stack architecture of a smart transportation ecosystem supporting 1,800+ drivers and 2,500+ riders, building the core backend with PHP/Workerman.</li>
|
||||||
<li>Built a proprietary mapping platform (IntaleqMaps) on OpenStreetMap, eliminating reliance on Google Maps API and saving $10,000+/month in operational costs.</li>
|
<li>Architected the proprietary mapping platform (IntaleqMaps), orchestrating GraphHopper routing, OSM tile serving, and PostGIS spatial queries via a NestJS API layer, saving $10,000+/month in operational costs.</li>
|
||||||
<li>Designed secure, custom payment infrastructure for environments lacking standard payment APIs, ensuring high-availability transaction integrity.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="job-block">
|
<div class="job-block">
|
||||||
<div class="job-header">
|
<div class="job-header">
|
||||||
<div class="job-title">Co-Founder & Lead Developer — Tripz Egypt</div>
|
<div class="job-title">Founding Engineer & Lead Backend Architect — Tripz Egypt | </div>
|
||||||
<div class="job-meta">Jan 2024 – Present | Cairo / Remote</div>
|
<div class="job-meta">Jan 2023 – Present | Cairo / Remote</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Architected Egypt's homegrown ride-hailing platform, managing the entire distributed ecosystem with real-time tracking and dispatching.</li>
|
<li>Co-founded and architected Egypt's homegrown ride-hailing platform, managing the entire distributed ecosystem with real-time tracking for 4,318 drivers and 2,464 riders.</li>
|
||||||
<li>Implemented robust microservices for real-time driver/rider matching and route optimization using event-driven architecture.</li>
|
<li>Implemented robust event-driven architecture for real-time driver/rider matching and WebSockets-based route optimization.</li>
|
||||||
|
<li>Integrated local digital payment gateways and driver payout systems, supporting an 8% commission structure that drove a $0.78 Customer Acquisition Cost.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="job-block">
|
<div class="job-block">
|
||||||
<div class="job-header">
|
<div class="job-header">
|
||||||
<div class="job-title">Mobile Solutions Architect — Freelance</div>
|
<div class="job-title">Senior Systems Architect — Mobile & Backend (Freelance) | </div>
|
||||||
<div class="job-meta">Jan 2017 – Dec 2023 | Jordan / Remote</div>
|
<div class="job-meta">Jan 2017 – Dec 2023 | Jordan / Remote</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Delivered 25+ production enterprise applications across GIS, FinTech, HR, and utilities for clients across the MENA region.</li>
|
<li>Delivered 25+ production enterprise applications across GIS, FinTech, and utilities, leading full lifecycle architecture for clients in the MENA region.</li>
|
||||||
<li>Integrated AI vision models for document analysis (KYC) and automated invoice processing pipelines.</li>
|
<li>Developed Nabih (AI Smart Responder): designed and built an intelligent virtual assistant system connecting a Flutter mobile application (GetX/Cubit) with custom PHP backend APIs.</li>
|
||||||
|
<li>Developed Musadaq (AI-Powered Invoice Processing): engineered an automated KYC and invoice document processor using AI vision models and asynchronous processing pipelines.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="job-block">
|
<div class="section-title">Notable Projects</div>
|
||||||
<div class="job-header">
|
<ul>
|
||||||
<div class="job-title">Operations & Logistics Officer — Jordan Armed Forces</div>
|
<li><strong>IntaleqMaps Engine:</strong> Engineered a proprietary mapping platform orchestrating GraphHopper routing, OSM tile serving, and PostGIS spatial queries via a NestJS API layer, replacing Google Maps API across the MENA region.</li>
|
||||||
<div class="job-meta">Oct 2003 – Nov 2023 | Jordan</div>
|
<li><strong>Nabih AI Virtual Assistant:</strong> Architected an automated customer support solution connecting a Flutter application (GetX/Cubit) to custom high-concurrency PHP backend APIs.</li>
|
||||||
<div class="clear"></div>
|
<li><strong>Musadaq AI Document Processor:</strong> Engineered an automated KYC and invoice document processing platform utilizing AI vision models and async processing pipelines.</li>
|
||||||
</div>
|
<li><strong>Meta Ads Manager & SaaS Gateways:</strong> Deployed multi-tenant platforms using NestJS/React (Meta Ads Manager) and high-concurrency Node.js (WhatsApp bridge managing headless Puppeteer sessions in Docker) to automate operations.</li>
|
||||||
<ul>
|
</ul>
|
||||||
<li>Retired Lieutenant Colonel. Directed logistics and crisis management operations, leading teams of 50+ personnel.</li>
|
|
||||||
<li>Applied rigorous, security-first methodologies to organizational leadership, disaster recovery, and operational planning.</li>
|
<div class="section-title">Open Source Contributions</div>
|
||||||
</ul>
|
<ul>
|
||||||
</div>
|
<li><strong>intaleq_maps (Flutter SDK):</strong> Published on <a href="https://pub.dev/packages/intaleq_maps" style="color: #1a237e; text-decoration: none; font-weight: bold;">pub.dev/packages/intaleq_maps</a>. Custom map rendering, offline caching, and route plotting package optimized for low-bandwidth environments.</li>
|
||||||
|
<li><strong>intaleq-maps-gl (NPM Library):</strong> Published on <a href="https://libraries.io/npm/intaleq-maps-gl" style="color: #1a237e; text-decoration: none; font-weight: bold;">npmjs.com/package/intaleq-maps-gl</a>. Web-based Mapbox GL compatible library optimized for custom OSM tiles and routing integration.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="section-title">Education & Certifications</div>
|
<div class="section-title">Education & Certifications</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>BS Mathematics</strong>, Mutah University (2003–2007)</li>
|
<li><strong>BS Mathematics (Applied Computing & Algorithms)</strong>, Mutah University (2003–2007)</li>
|
||||||
<li>Google Data Analytics Professional Certificate</li>
|
<li><strong>Google Data Analytics</strong> Professional Certificate (Coursera)</li>
|
||||||
<li>IBM Data Science Professional Certificate</li>
|
<li><strong>IBM Data Analyst</strong> Professional Certificate (Coursera)</li>
|
||||||
<li>Meta Mobile Development Certificate</li>
|
<li><strong>Meta APIs & Django Web Framework</strong> Course Certificates (Coursera)</li>
|
||||||
<li><em>Total of 51 professional certifications across software engineering, AI, and enterprise architecture.</em></li>
|
<li><strong>AWS Cloud Practitioner & Cloud Architecture Fundamentals</strong></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-title">Languages</div>
|
||||||
|
<p><strong>Arabic:</strong> Native | <strong>English:</strong> Professional Working Proficiency | <strong>Turkish:</strong> Limited Working Proficiency</p>
|
||||||
|
|
||||||
|
<div class="section-title">Availability & Work Authorization</div>
|
||||||
|
<p style="text-align: center; font-weight: bold; color: #1a237e; margin-top: 10px;">
|
||||||
|
Available Immediately | Open to Remote, Hybrid & Relocation (GCC/Europe/MENA) | Valid Passport
|
||||||
|
</p>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
101
filler.js
Normal file
101
filler.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// filler.js — Script executed on the active tab to auto-fill form fields.
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// Retrieve the profile data passed from the popup/scripting context
|
||||||
|
const profile = window.__autofillProfileData;
|
||||||
|
if (!profile) {
|
||||||
|
console.error("Autofill profile data not found.");
|
||||||
|
return "Error: Profile data not found";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Starting auto-fill with profile:", profile);
|
||||||
|
|
||||||
|
// Helper to find associated label text for an input
|
||||||
|
function getLabelText(input) {
|
||||||
|
let labelText = "";
|
||||||
|
|
||||||
|
// 1. Check aria-label or placeholder
|
||||||
|
if (input.getAttribute("aria-label")) {
|
||||||
|
labelText += " " + input.getAttribute("aria-label");
|
||||||
|
}
|
||||||
|
if (input.getAttribute("placeholder")) {
|
||||||
|
labelText += " " + input.getAttribute("placeholder");
|
||||||
|
}
|
||||||
|
if (input.getAttribute("id")) {
|
||||||
|
const label = document.querySelector(`label[for="${input.getAttribute("id")}"]`);
|
||||||
|
if (label) {
|
||||||
|
labelText += " " + label.innerText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check parent label
|
||||||
|
let parent = input.parentElement;
|
||||||
|
while (parent && parent !== document.body) {
|
||||||
|
if (parent.tagName === "LABEL") {
|
||||||
|
labelText += " " + parent.innerText;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check name or id attributes
|
||||||
|
if (input.name) labelText += " " + input.name;
|
||||||
|
if (input.id) labelText += " " + input.id;
|
||||||
|
|
||||||
|
return labelText.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all input, textarea, and select elements
|
||||||
|
const inputs = document.querySelectorAll("input, textarea, select");
|
||||||
|
let filledCount = 0;
|
||||||
|
|
||||||
|
inputs.forEach(input => {
|
||||||
|
// Skip hidden or read-only elements
|
||||||
|
if (input.type === "hidden" || input.disabled || input.readOnly) return;
|
||||||
|
|
||||||
|
const label = getLabelText(input);
|
||||||
|
|
||||||
|
let valToSet = null;
|
||||||
|
|
||||||
|
// Matching logic based on common field names
|
||||||
|
if (label.includes("email") || label.includes("mail")) {
|
||||||
|
valToSet = profile.email;
|
||||||
|
} else if (label.includes("phone") || label.includes("tel") || label.includes("mobile") || label.includes("contact")) {
|
||||||
|
valToSet = profile.phone;
|
||||||
|
} else if (label.includes("linkedin")) {
|
||||||
|
valToSet = profile.linkedin;
|
||||||
|
} else if (label.includes("github")) {
|
||||||
|
valToSet = profile.github;
|
||||||
|
} else if (label.includes("first name") || label.includes("firstname")) {
|
||||||
|
valToSet = profile.firstName;
|
||||||
|
} else if (label.includes("last name") || label.includes("lastname")) {
|
||||||
|
valToSet = profile.lastName;
|
||||||
|
} else if (label.includes("full name") || label.includes("fullname") || (label.includes("name") && !label.includes("company") && !label.includes("school") && !label.includes("employer"))) {
|
||||||
|
valToSet = profile.fullName;
|
||||||
|
} else if (label.includes("website") || label.includes("portfolio") || label.includes("personal link")) {
|
||||||
|
valToSet = profile.portfolio || profile.linkedin;
|
||||||
|
} else if (label.includes("cover letter") || label.includes("describe") || label.includes("introduce yourself")) {
|
||||||
|
valToSet = profile.summary || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valToSet !== null && valToSet !== undefined) {
|
||||||
|
// Set the value
|
||||||
|
input.value = valToSet;
|
||||||
|
|
||||||
|
// Highlight the filled field briefly
|
||||||
|
const originalBg = input.style.backgroundColor;
|
||||||
|
input.style.backgroundColor = "rgba(0, 214, 126, 0.2)";
|
||||||
|
input.style.transition = "background-color 0.5s ease";
|
||||||
|
setTimeout(() => {
|
||||||
|
input.style.backgroundColor = originalBg;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
// Trigger change/input events so any framework (React/Angular/Vue) registers the value
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
filledCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `Filled ${filledCount} fields successfully!`;
|
||||||
|
})();
|
||||||
@@ -1,20 +1,63 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "LinkedIn Job Analyzer",
|
"name": "LinkedIn Job Analyzer",
|
||||||
"version": "1.2.0",
|
"version": "1.5.0",
|
||||||
"description": "AI-powered job analysis tool for LinkedIn — personal use",
|
"description": "AI-powered job analysis + smart comment generator for LinkedIn — personal use",
|
||||||
"permissions": ["storage", "activeTab", "scripting"],
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"activeTab",
|
||||||
|
"scripting",
|
||||||
|
"clipboardWrite",
|
||||||
|
"microphone"
|
||||||
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://www.linkedin.com/*",
|
"https://www.linkedin.com/*",
|
||||||
|
"https://claude.ai/*",
|
||||||
"https://cv.intaleqapp.com/*",
|
"https://cv.intaleqapp.com/*",
|
||||||
"https://generativelanguage.googleapis.com/*"
|
"https://generativelanguage.googleapis.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [{
|
"content_scripts": [
|
||||||
"matches": ["https://www.linkedin.com/jobs/*"],
|
{
|
||||||
"js": ["prompts.js", "content.js"],
|
"matches": [
|
||||||
"css": ["overlay.css"],
|
"https://www.linkedin.com/jobs/*"
|
||||||
"run_at": "document_idle"
|
],
|
||||||
}],
|
"js": [
|
||||||
|
"prompts.js",
|
||||||
|
"content.js"
|
||||||
|
],
|
||||||
|
"css": [
|
||||||
|
"overlay.css"
|
||||||
|
],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://www.linkedin.com/feed/",
|
||||||
|
"https://www.linkedin.com/feed/*",
|
||||||
|
"https://www.linkedin.com/"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"post_feed.js"
|
||||||
|
],
|
||||||
|
"css": [
|
||||||
|
"post_feed.css"
|
||||||
|
],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://www.linkedin.com/search/results/people/*",
|
||||||
|
"https://www.linkedin.com/search/results/all/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"search_analyzer.js"
|
||||||
|
],
|
||||||
|
"css": [
|
||||||
|
"search_analyzer.css"
|
||||||
|
],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
"default_icon": {
|
"default_icon": {
|
||||||
@@ -23,6 +66,15 @@
|
|||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": [
|
||||||
|
"speech.html",
|
||||||
|
"speech.js"
|
||||||
|
],
|
||||||
|
"matches": ["<all_urls>"]
|
||||||
|
}
|
||||||
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js"
|
"service_worker": "background.js"
|
||||||
},
|
},
|
||||||
@@ -31,4 +83,4 @@
|
|||||||
"48": "icons/icon48.png",
|
"48": "icons/icon48.png",
|
||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
70
permission.html
Normal file
70
permission.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>السماح باستخدام الميكروفون</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background-color: #0a0a0f;
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: #1a1a26;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(108, 99, 255, 0.2);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #6c63ff;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #a0a0b8;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: linear-gradient(135deg, #6c63ff, #4834d4);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 15px rgba(108, 99, 255, 0.4);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">🎤</div>
|
||||||
|
<h1>مطلوب إذن الميكروفون</h1>
|
||||||
|
<p>ميزة الإملاء الصوتي تحتاج إلى إذن للوصول إلى الميكروفون لتعمل بشكل صحيح في الخلفية.</p>
|
||||||
|
<p>يرجى الضغط على الزر أدناه والموافقة على طلب الصلاحية من المتصفح.</p>
|
||||||
|
<button class="btn" id="grantBtn">منح صلاحية الميكروفون</button>
|
||||||
|
<p id="status" style="margin-top: 20px; font-weight: bold;"></p>
|
||||||
|
</div>
|
||||||
|
<script src="permission.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
permission.js
Normal file
19
permission.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
document.getElementById('grantBtn').addEventListener('click', async () => {
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
// Stop the tracks immediately since we only needed to ask for permission
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
status.style.color = '#00d67e';
|
||||||
|
status.textContent = '✅ تم منح الصلاحية بنجاح! يمكنك إغلاق هذه النافذة والعودة إلى الإضافة.';
|
||||||
|
|
||||||
|
// Auto close after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
window.close();
|
||||||
|
}, 3000);
|
||||||
|
} catch (err) {
|
||||||
|
status.style.color = '#ff4d6d';
|
||||||
|
status.textContent = '❌ حدث خطأ أو تم رفض الصلاحية: ' + err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
31
popup.html
31
popup.html
@@ -414,6 +414,37 @@
|
|||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
||||||
|
<!-- VOICE DICTATION -->
|
||||||
|
<div class="section" style="background: linear-gradient(135deg, rgba(108, 99, 255, 0.15) 0%, rgba(155, 93, 229, 0.15) 100%); border-color: var(--accent);">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">🎤 إملاء صوتي ذكي</span>
|
||||||
|
<button class="btn btn-sm" id="copy-dictation-btn" style="display:none; font-family: Tahoma, sans-serif;">📋 نسخ</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-bottom: 12px;">
|
||||||
|
<button id="dictation-mic-btn" style="width: 60px; height: 60px; border-radius: 50%; border: none; background: linear-gradient(135deg, var(--accent), #9b5de5); color: white; font-size: 24px; cursor: pointer; transition: all 0.2s;">
|
||||||
|
🎤
|
||||||
|
</button>
|
||||||
|
<div id="dictation-status" style="font-size: 11px; color: var(--text-secondary); margin-top: 8px; font-family: Tahoma, sans-serif;">اضغط للتحدث (بالعربية)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea id="dictation-result" placeholder="النص سيظهر هنا..." style="min-height: 80px; direction: rtl; font-family: Tahoma, sans-serif;"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QUICK ACTIONS -->
|
||||||
|
<div class="section" style="background: linear-gradient(135deg, rgba(108, 99, 255, 0.15) 0%, rgba(155, 93, 229, 0.15) 100%); border-color: var(--accent);">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">⚡ Quick Actions</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="autofill-btn">
|
||||||
|
✨ Auto-fill Form on Current Page
|
||||||
|
</button>
|
||||||
|
<p style="font-size: 11px; color: var(--text-secondary); margin-top: 8px; text-align: center;">
|
||||||
|
Fills fields on the current page using your profile details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- API KEY SECTION -->
|
<!-- API KEY SECTION -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
|
|||||||
353
popup.js
353
popup.js
@@ -39,8 +39,15 @@ function loadSettings() {
|
|||||||
document.getElementById('api-key-input').value = data.apiKey;
|
document.getElementById('api-key-input').value = data.apiKey;
|
||||||
setKeyStatus('ok');
|
setKeyStatus('ok');
|
||||||
}
|
}
|
||||||
document.getElementById('profile-textarea').value =
|
let profile = data.userProfile || DEFAULT_PROFILE;
|
||||||
data.userProfile || DEFAULT_PROFILE;
|
if (profile.includes('hamzaayed@intaleqapp.com') || profile.includes('hamzaayedflutter@gmail.com') || profile.includes('hamzaayed@tripz-egypt.com')) {
|
||||||
|
profile = profile
|
||||||
|
.replace(/hamzaayed@intaleqapp\.com/g, 'hamzaayed.dev@gmail.com')
|
||||||
|
.replace(/hamzaayedflutter@gmail\.com/g, 'hamzaayed.dev@gmail.com')
|
||||||
|
.replace(/hamzaayed@tripz-egypt\.com/g, 'hamzaayed.dev@gmail.com');
|
||||||
|
chrome.storage.sync.set({ userProfile: profile });
|
||||||
|
}
|
||||||
|
document.getElementById('profile-textarea').value = profile;
|
||||||
document.getElementById('lang-select').value =
|
document.getElementById('lang-select').value =
|
||||||
data.language || 'auto';
|
data.language || 'auto';
|
||||||
});
|
});
|
||||||
@@ -104,7 +111,7 @@ document.getElementById('test-key-btn').addEventListener('click', async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=${apiKey}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -163,6 +170,346 @@ document.getElementById('clear-all-btn').addEventListener('click', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Autofill Functionality ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseProfileText(text) {
|
||||||
|
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
||||||
|
const phoneRegex = /\+?[0-9]{1,4}[ \t.-]?[0-9]{3,4}[ \t.-]?[0-9]{3,4}/;
|
||||||
|
const linkedinRegex = /(https?:\/\/)?(www\.)?linkedin\.com\/in\/[a-zA-Z0-9_-]+/;
|
||||||
|
const githubRegex = /(https?:\/\/)?(www\.)?github\.com\/[a-zA-Z0-9_-]+/;
|
||||||
|
|
||||||
|
const emailMatch = text.match(emailRegex);
|
||||||
|
const phoneMatch = text.match(phoneRegex);
|
||||||
|
const linkedinMatch = text.match(linkedinRegex);
|
||||||
|
const githubMatch = text.match(githubRegex);
|
||||||
|
|
||||||
|
// Extract Name (first line usually)
|
||||||
|
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
let fullName = "Hamza Ayed";
|
||||||
|
if (lines.length > 0) {
|
||||||
|
const firstLine = lines[0].replace(/—.*/, '').replace(/:.*/, '').trim();
|
||||||
|
fullName = firstLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameParts = fullName.split(' ');
|
||||||
|
const firstName = nameParts[0] || "";
|
||||||
|
const lastName = nameParts.slice(1).join(' ') || "";
|
||||||
|
|
||||||
|
// Extract Summary
|
||||||
|
let summary = "";
|
||||||
|
const summaryIdx = text.toLowerCase().indexOf("summary:");
|
||||||
|
if (summaryIdx !== -1) {
|
||||||
|
const skillsIdx = text.toLowerCase().indexOf("core skills:", summaryIdx);
|
||||||
|
if (skillsIdx !== -1) {
|
||||||
|
summary = text.substring(summaryIdx + 8, skillsIdx).trim();
|
||||||
|
} else {
|
||||||
|
summary = text.substring(summaryIdx + 8, summaryIdx + 500).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullName,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: emailMatch ? emailMatch[0] : "",
|
||||||
|
phone: phoneMatch ? phoneMatch[0] : "",
|
||||||
|
linkedin: linkedinMatch ? linkedinMatch[0] : "",
|
||||||
|
github: githubMatch ? githubMatch[0] : "",
|
||||||
|
summary,
|
||||||
|
cvText: text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('autofill-btn').addEventListener('click', async () => {
|
||||||
|
const userProfile = document.getElementById('profile-textarea').value.trim();
|
||||||
|
if (!userProfile) {
|
||||||
|
showToast('⚠️ Please enter profile info first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileData = parseProfileText(userProfile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tab) {
|
||||||
|
showToast('❌ No active tab found', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the profile data on the tab window context
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
func: (data) => {
|
||||||
|
window.__autofillProfileData = data;
|
||||||
|
},
|
||||||
|
args: [profileData]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute the filler script
|
||||||
|
const results = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
files: ['filler.js']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results && results[0]) {
|
||||||
|
showToast('✨ ' + results[0].result, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('✨ Autofill completed!', 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast('❌ Error: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Voice Dictation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initDictation() {
|
||||||
|
const statusEl = document.getElementById('dictation-status');
|
||||||
|
const micBtn = document.getElementById('dictation-mic-btn');
|
||||||
|
const resultArea = document.getElementById('dictation-result');
|
||||||
|
const copyBtn = document.getElementById('copy-dictation-btn');
|
||||||
|
|
||||||
|
let isRecording = false;
|
||||||
|
let finalTranscript = '';
|
||||||
|
let interimTranscript = '';
|
||||||
|
|
||||||
|
function updateCopyBtnVisibility() {
|
||||||
|
if (resultArea.value.trim()) {
|
||||||
|
copyBtn.style.display = 'inline-flex';
|
||||||
|
} else {
|
||||||
|
copyBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultArea.addEventListener('input', updateCopyBtnVisibility);
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (!isRecording) return;
|
||||||
|
isRecording = false;
|
||||||
|
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
|
const activeTab = tabs[0];
|
||||||
|
if (activeTab && activeTab.id) {
|
||||||
|
chrome.tabs.sendMessage(activeTab.id, { type: 'STOP_RECORDING_FROM_POPUP' }, () => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
console.warn('Could not send STOP message to tab:', chrome.runtime.lastError.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
micBtn.style.background = 'linear-gradient(135deg, var(--accent), #9b5de5)';
|
||||||
|
micBtn.innerHTML = '🎤';
|
||||||
|
|
||||||
|
updateCopyBtnVisibility();
|
||||||
|
|
||||||
|
if (!statusEl.textContent.includes('❌')) {
|
||||||
|
statusEl.textContent = 'اضغط للتحدث (بالعربية)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to messages from the injected script
|
||||||
|
chrome.runtime.onMessage.addListener((message) => {
|
||||||
|
if (message.type === 'SPEECH_START_SUCCESS') {
|
||||||
|
isRecording = true;
|
||||||
|
micBtn.style.background = 'linear-gradient(135deg, #ff4d6d, #c9184a)';
|
||||||
|
micBtn.innerHTML = '🔴';
|
||||||
|
statusEl.textContent = 'جارٍ الاستماع... اضغط للإيقاف';
|
||||||
|
} else if (message.type === 'SPEECH_RESULT') {
|
||||||
|
interimTranscript = message.payload.interimText || '';
|
||||||
|
finalTranscript = message.payload.finalText || '';
|
||||||
|
resultArea.value = (finalTranscript + interimTranscript).trim();
|
||||||
|
updateCopyBtnVisibility();
|
||||||
|
} else if (message.type === 'SPEECH_ERROR') {
|
||||||
|
console.error('Speech recognition error', message.payload.error);
|
||||||
|
if (message.payload.error === 'not-allowed') {
|
||||||
|
statusEl.textContent = '❌ يرجى السماح للميكروفون من إعدادات المتصفح';
|
||||||
|
chrome.tabs.create({ url: chrome.runtime.getURL('permission.html') });
|
||||||
|
} else if (message.payload.error !== 'no-speech') {
|
||||||
|
statusEl.textContent = '❌ خطأ: ' + message.payload.error;
|
||||||
|
}
|
||||||
|
stopRecording();
|
||||||
|
} else if (message.type === 'SPEECH_END') {
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
micBtn.addEventListener('click', async () => {
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecording();
|
||||||
|
} else {
|
||||||
|
// First, get the active tab
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
|
const activeTab = tabs[0];
|
||||||
|
|
||||||
|
// Cannot inject into chrome:// or chrome-extension:// pages
|
||||||
|
if (!activeTab || !activeTab.url || activeTab.url.startsWith('chrome://') || activeTab.url.startsWith('chrome-extension://') || activeTab.url.startsWith('edge://')) {
|
||||||
|
statusEl.textContent = '❌ يرجى فتح موقع عادي أولاً (مثل جوجل أو لينكدإن)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a loading state until SPEECH_START_SUCCESS is received
|
||||||
|
micBtn.style.background = 'linear-gradient(135deg, #ffb3c1, #ff758f)';
|
||||||
|
micBtn.innerHTML = '⏳';
|
||||||
|
statusEl.textContent = 'جاري تهيئة الميكروفون...';
|
||||||
|
finalTranscript = '';
|
||||||
|
interimTranscript = '';
|
||||||
|
resultArea.value = '';
|
||||||
|
copyBtn.style.display = 'none';
|
||||||
|
|
||||||
|
chrome.scripting.executeScript({
|
||||||
|
target: { tabId: activeTab.id },
|
||||||
|
func: (lang) => {
|
||||||
|
// Clean up any existing instances in this tab first
|
||||||
|
if (window.__ljaSpeechActiveRecognition) {
|
||||||
|
try { window.__ljaSpeechActiveRecognition.stop(); } catch(e) {}
|
||||||
|
}
|
||||||
|
if (window.__ljaSpeechStopListener) {
|
||||||
|
try { chrome.runtime.onMessage.removeListener(window.__ljaSpeechStopListener); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__ljaDictationActive = true;
|
||||||
|
window.__ljaSpeechUserStopped = false;
|
||||||
|
window.__ljaSpeechAccumulatedText = '';
|
||||||
|
window.__ljaSpeechCurrentSessionFinalText = '';
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) {
|
||||||
|
chrome.runtime.sendMessage({ type: 'SPEECH_ERROR', payload: { error: 'not-supported' } });
|
||||||
|
window.__ljaDictationActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recognition;
|
||||||
|
|
||||||
|
function startRecognition() {
|
||||||
|
if (window.__ljaSpeechUserStopped) return;
|
||||||
|
|
||||||
|
recognition = new SpeechRecognition();
|
||||||
|
window.__ljaSpeechActiveRecognition = recognition;
|
||||||
|
|
||||||
|
recognition.lang = lang;
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.maxAlternatives = 1;
|
||||||
|
|
||||||
|
recognition.onstart = () => {
|
||||||
|
chrome.runtime.sendMessage({ type: 'SPEECH_START_SUCCESS' });
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
let interimText = '';
|
||||||
|
let sessionFinalText = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i];
|
||||||
|
if (result.isFinal) {
|
||||||
|
sessionFinalText += result[0].transcript + ' ';
|
||||||
|
} else {
|
||||||
|
interimText += result[0].transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__ljaSpeechCurrentSessionFinalText = sessionFinalText;
|
||||||
|
|
||||||
|
const fullFinalText = window.__ljaSpeechAccumulatedText + sessionFinalText;
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'SPEECH_RESULT',
|
||||||
|
payload: {
|
||||||
|
interimText: interimText,
|
||||||
|
finalText: fullFinalText
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = (event) => {
|
||||||
|
console.error('[LJA Dictation] Error:', event.error);
|
||||||
|
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
|
||||||
|
chrome.runtime.sendMessage({ type: 'SPEECH_ERROR', payload: { error: event.error } });
|
||||||
|
window.__ljaDictationActive = false;
|
||||||
|
window.__ljaSpeechUserStopped = true;
|
||||||
|
window.__ljaSpeechActiveRecognition = null;
|
||||||
|
} else {
|
||||||
|
console.warn('[LJA Dictation] Recoverable error:', event.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (window.__ljaSpeechUserStopped) {
|
||||||
|
// User explicitly stopped
|
||||||
|
chrome.runtime.sendMessage({ type: 'SPEECH_END' });
|
||||||
|
window.__ljaDictationActive = false;
|
||||||
|
window.__ljaSpeechActiveRecognition = null;
|
||||||
|
} else {
|
||||||
|
// Ended due to browser timeout / silence, restart it
|
||||||
|
window.__ljaSpeechAccumulatedText += window.__ljaSpeechCurrentSessionFinalText;
|
||||||
|
window.__ljaSpeechCurrentSessionFinalText = '';
|
||||||
|
console.log('[LJA Dictation] Silence/timeout. Restarting recognition...');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!window.__ljaSpeechUserStopped) {
|
||||||
|
startRecognition();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
recognition.start();
|
||||||
|
} catch (e) {
|
||||||
|
chrome.runtime.sendMessage({ type: 'SPEECH_ERROR', payload: { error: e.message } });
|
||||||
|
window.__ljaDictationActive = false;
|
||||||
|
window.__ljaSpeechUserStopped = true;
|
||||||
|
window.__ljaSpeechActiveRecognition = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for STOP message from popup
|
||||||
|
window.__ljaSpeechStopListener = (msg, sender, sendResponse) => {
|
||||||
|
if (msg.type === 'STOP_RECORDING_FROM_POPUP') {
|
||||||
|
window.__ljaSpeechUserStopped = true;
|
||||||
|
try { recognition.stop(); } catch(e) {}
|
||||||
|
window.__ljaDictationActive = false;
|
||||||
|
window.__ljaSpeechActiveRecognition = null;
|
||||||
|
if (window.__ljaSpeechStopListener) {
|
||||||
|
try { chrome.runtime.onMessage.removeListener(window.__ljaSpeechStopListener); } catch(e) {}
|
||||||
|
window.__ljaSpeechStopListener = null;
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
chrome.runtime.onMessage.addListener(window.__ljaSpeechStopListener);
|
||||||
|
|
||||||
|
startRecognition();
|
||||||
|
},
|
||||||
|
args: ['ar-SA']
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to inject speech recognition:', err);
|
||||||
|
statusEl.textContent = '❌ فشل الاتصال بالصفحة الحالية';
|
||||||
|
micBtn.style.background = 'linear-gradient(135deg, var(--accent), #9b5de5)';
|
||||||
|
micBtn.innerHTML = '🎤';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(resultArea.value);
|
||||||
|
const originalText = copyBtn.textContent;
|
||||||
|
copyBtn.textContent = '✅ تم النسخ!';
|
||||||
|
setTimeout(() => {
|
||||||
|
copyBtn.textContent = originalText;
|
||||||
|
resultArea.value = '';
|
||||||
|
updateCopyBtnVisibility();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Init ────────────────────────────────────────────────────────────────────
|
// ─── Init ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
initDictation();
|
||||||
|
|
||||||
|
|||||||
167
post_feed.css
Normal file
167
post_feed.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/* ============================================================
|
||||||
|
post_feed.css — Smart Comment Feature Styles
|
||||||
|
Scoped to .lja-comment-btn and .lja-comment-box
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ── Smart Comment Button ─────────────────────────────────── */
|
||||||
|
.lja-comment-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: linear-gradient(135deg, #6c63ff, #4f46e5);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
box-shadow: 0 2px 8px rgba(108, 99, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-comment-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 14px rgba(108, 99, 255, 0.45);
|
||||||
|
background: linear-gradient(135deg, #7c73ff, #5f56f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-comment-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner inside button */
|
||||||
|
.lja-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: lja-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lja-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Comment Suggestion Box ───────────────────────────────── */
|
||||||
|
.lja-comment-box {
|
||||||
|
margin: 10px 12px;
|
||||||
|
background: linear-gradient(145deg, #1a1a2e, #16213e);
|
||||||
|
border: 1px solid rgba(108, 99, 255, 0.35);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: lja-fadeIn 0.25s ease;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lja-fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.lja-cb-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(108, 99, 255, 0.15);
|
||||||
|
border-bottom: 1px solid rgba(108, 99, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-cb-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-cb-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #a89cff;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-cb-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-cb-close:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 80, 80, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editable text area */
|
||||||
|
.lja-cb-text {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 160px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #e0deff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-cb-text:focus {
|
||||||
|
background: rgba(108, 99, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons row */
|
||||||
|
.lja-cb-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-cb-actions button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid rgba(108, 99, 255, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(108, 99, 255, 0.12);
|
||||||
|
color: #b0a8ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-cb-actions button:hover {
|
||||||
|
background: rgba(108, 99, 255, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(108, 99, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paste button — primary emphasis */
|
||||||
|
.lja-cb-paste {
|
||||||
|
background: linear-gradient(135deg, rgba(108,99,255,0.3), rgba(79,70,229,0.3)) !important;
|
||||||
|
border-color: rgba(108, 99, 255, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-cb-paste:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(108,99,255,0.55), rgba(79,70,229,0.55)) !important;
|
||||||
|
}
|
||||||
450
post_feed.js
Normal file
450
post_feed.js
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
// post_feed.js — LinkedIn Feed Smart Comment Generator
|
||||||
|
// Operates ONLY on linkedin.com/feed pages — fully independent from content.js
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BUTTON_CLASS = 'lja-comment-btn';
|
||||||
|
const BOX_CLASS = 'lja-comment-box';
|
||||||
|
|
||||||
|
// ─── Utility: get stored settings ────────────────────────────────────────
|
||||||
|
function getSettings() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!chrome || !chrome.storage) {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.storage.sync.get(['apiKey', 'language'], (syncData) => {
|
||||||
|
if (syncData && syncData.apiKey) {
|
||||||
|
resolve(syncData);
|
||||||
|
} else {
|
||||||
|
chrome.storage.local.get(['apiKey', 'language'], (localData) => {
|
||||||
|
resolve(localData || {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Extract post text from a feed item ──────────────────────────────────
|
||||||
|
function extractPostText(postEl) {
|
||||||
|
const selectors = [
|
||||||
|
'[data-testid="expandable-text-box"]', // New LinkedIn UI
|
||||||
|
'.feed-shared-update-v2__description .break-words span[dir]',
|
||||||
|
'.feed-shared-text-view span[dir]',
|
||||||
|
'.update-components-text span[dir]',
|
||||||
|
'.feed-shared-update-v2__description',
|
||||||
|
'.feed-shared-text',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = postEl.querySelector(sel);
|
||||||
|
if (el) {
|
||||||
|
// Strip out the "... more" text if it exists
|
||||||
|
const text = el.textContent.replace(/…\s*more/gi, '').trim();
|
||||||
|
if (text.length > 20) return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Find the action bar in a post ───────────────────────────────────────
|
||||||
|
function findActionBar(postEl) {
|
||||||
|
const selectors = [
|
||||||
|
'.feed-shared-social-action-bar',
|
||||||
|
'.social-actions-bar',
|
||||||
|
'[data-urn] .feed-shared-social-action-bar',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = postEl.querySelector(sel);
|
||||||
|
if (el) return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New UI fallback: find the comment button SVG and get its parent container
|
||||||
|
const commentSvg = postEl.querySelector('svg#comment-small, svg[data-test-icon="comment-small"]');
|
||||||
|
if (commentSvg) {
|
||||||
|
const btn = commentSvg.closest('button');
|
||||||
|
if (btn && btn.parentNode) {
|
||||||
|
return btn.parentNode; // This is the action bar container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Find comment input field ─────────────────────────────────────────────
|
||||||
|
function findCommentInput(postEl) {
|
||||||
|
const selectors = [
|
||||||
|
'.comments-comment-box__form-container .ql-editor',
|
||||||
|
'.comments-comment-texteditor .ql-editor',
|
||||||
|
'.ql-editor[contenteditable="true"]',
|
||||||
|
'[contenteditable="true"][role="textbox"]',
|
||||||
|
'div[contenteditable="true"]'
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = postEl.querySelector(sel);
|
||||||
|
if (el) return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fill comment input (handles LinkedIn's Quill editor) ────────────────
|
||||||
|
function fillCommentInput(inputEl, text) {
|
||||||
|
inputEl.focus();
|
||||||
|
inputEl.innerHTML = '';
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
inputEl.dispatchEvent(new InputEvent('input', { bubbles: true, data: text }));
|
||||||
|
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create the comment suggestion box ───────────────────────────────────
|
||||||
|
function createCommentBox(postEl, commentText, arabicSummary) {
|
||||||
|
const existing = postEl.querySelector('.' + BOX_CLASS);
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const isRTL = /[\u0600-\u06FF]/.test(commentText);
|
||||||
|
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = BOX_CLASS;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="lja-cb-header">
|
||||||
|
<span class="lja-cb-icon">🤖</span>
|
||||||
|
<span class="lja-cb-title">Smart Comment & Analysis</span>
|
||||||
|
<button class="lja-cb-close" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 10px 14px; background: rgba(108, 99, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.06);">
|
||||||
|
<div style="font-size: 11px; color: #ff8787; margin-bottom: 4px; font-weight: 600;">🕵️ التحليل والمصداقية:</div>
|
||||||
|
<div style="font-size: 12px; color: #e0deff; line-height: 1.5; text-align: right;" dir="rtl">
|
||||||
|
${arabicSummary}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="lja-cb-text"
|
||||||
|
dir="${isRTL ? 'rtl' : 'ltr'}"
|
||||||
|
style="text-align: ${isRTL ? 'right' : 'left'}; font-family: monospace; font-size: 13px;"
|
||||||
|
>${commentText}</textarea>
|
||||||
|
<div class="lja-cb-actions">
|
||||||
|
<button class="lja-cb-copy">📋 Copy</button>
|
||||||
|
<button class="lja-cb-paste">✅ Paste into Comment</button>
|
||||||
|
<button class="lja-cb-regen">🔄 Regenerate</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
box.querySelector('.lja-cb-close').addEventListener('click', () => box.remove());
|
||||||
|
|
||||||
|
box.querySelector('.lja-cb-copy').addEventListener('click', function () {
|
||||||
|
const text = box.querySelector('.lja-cb-text').value;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
this.textContent = '✅ Copied!';
|
||||||
|
setTimeout(() => { this.textContent = '📋 Copy'; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
box.querySelector('.lja-cb-paste').addEventListener('click', function () {
|
||||||
|
const text = box.querySelector('.lja-cb-text').value;
|
||||||
|
|
||||||
|
const commentTrigger = postEl.querySelector(
|
||||||
|
'.comment-button, [aria-label*="comment" i], [data-control-name="comment"], svg#comment-small'
|
||||||
|
);
|
||||||
|
let btnToClick = commentTrigger;
|
||||||
|
if (btnToClick && btnToClick.tagName.toLowerCase() === 'svg') {
|
||||||
|
btnToClick = btnToClick.closest('button') || btnToClick;
|
||||||
|
}
|
||||||
|
if (btnToClick) btnToClick.click();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const inputEl = findCommentInput(postEl);
|
||||||
|
if (inputEl) {
|
||||||
|
fillCommentInput(inputEl, text);
|
||||||
|
this.textContent = '✅ Done!';
|
||||||
|
setTimeout(() => { this.textContent = '✅ Paste into Comment'; }, 1500);
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.textContent = '📋 Copied! Paste manually';
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
box.querySelector('.lja-cb-regen').addEventListener('click', async function () {
|
||||||
|
box.remove();
|
||||||
|
await generateComment(postEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionBar = findActionBar(postEl);
|
||||||
|
if (actionBar) {
|
||||||
|
actionBar.parentNode.insertBefore(box, actionBar.nextSibling);
|
||||||
|
} else {
|
||||||
|
postEl.appendChild(box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core: call server and generate comment ───────────────────────────────
|
||||||
|
async function generateComment(postEl) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings || !settings.apiKey) {
|
||||||
|
alert('Please set your Gemini API key in the extension popup first.\n(Debug: Key not found in storage)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postText = extractPostText(postEl);
|
||||||
|
if (!postText) {
|
||||||
|
alert('Could not read post text. The post might be an image or video.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = postEl.querySelector('.' + BUTTON_CLASS);
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="lja-spinner"></span> Thinking...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'GEMINI_REQUEST',
|
||||||
|
payload: {
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
action: 'generateComment',
|
||||||
|
postText: postText
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON returned by Gemini
|
||||||
|
let resultData;
|
||||||
|
let rawText = response.data.comment || response.data;
|
||||||
|
|
||||||
|
// Clean up markdown blocks if the AI accidentally adds them
|
||||||
|
rawText = rawText.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
resultData = JSON.parse(rawText);
|
||||||
|
} catch (parseError) {
|
||||||
|
throw new Error('Failed to parse AI response. Raw output: ' + rawText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arabicSummary = resultData.arabic_summary || 'لم يتم توليد ملخص.';
|
||||||
|
const commentText = resultData.comment || '';
|
||||||
|
|
||||||
|
createCommentBox(postEl, commentText, arabicSummary);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LJA Feed]', e);
|
||||||
|
alert('Comment generation failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '💬 Smart Comment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core: call server and repurpose post ───────────────────────────────
|
||||||
|
async function repurposePost(postEl) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings || !settings.apiKey) {
|
||||||
|
alert('Please set your Gemini API key in the extension popup first.\n(Debug: Key not found in storage)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postText = extractPostText(postEl);
|
||||||
|
if (!postText) {
|
||||||
|
alert('Could not read post text. The post might be an image or video.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = postEl.querySelector('.lja-rewrite-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="lja-spinner"></span> Rewriting...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'GEMINI_REQUEST',
|
||||||
|
payload: {
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
action: 'repurposePost',
|
||||||
|
postText: postText
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultText = response.data.result || response.data;
|
||||||
|
|
||||||
|
// We reuse the comment box UI but style it differently, or just use the same box but change the title and actions.
|
||||||
|
createRepurposeBox(postEl, resultText);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LJA Feed]', e);
|
||||||
|
alert('Post rewriting failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '📝 Rewrite';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create the repurpose result box ─────────────────────────────────────
|
||||||
|
function createRepurposeBox(postEl, resultText) {
|
||||||
|
const existing = postEl.querySelector('.' + BOX_CLASS);
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const isRTL = /[\u0600-\u06FF]/.test(resultText);
|
||||||
|
|
||||||
|
// Split text into Post and Image Prompt if the separator exists
|
||||||
|
let postContent = resultText;
|
||||||
|
let imagePrompt = '';
|
||||||
|
const parts = resultText.split('--- IMAGE PROMPT ---');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
postContent = parts[0].trim();
|
||||||
|
imagePrompt = parts[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = BOX_CLASS;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="lja-cb-header" style="background: linear-gradient(135deg, #FF6B6B 0%, #C92A2A 100%);">
|
||||||
|
<span class="lja-cb-icon">✍️</span>
|
||||||
|
<span class="lja-cb-title">Rewritten Post</span>
|
||||||
|
<button class="lja-cb-close" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 12px; font-size: 13px; color: #666; font-weight: 500; border-bottom: 1px solid rgba(0,0,0,0.05);">
|
||||||
|
Post Content:
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="lja-cb-text"
|
||||||
|
dir="${isRTL ? 'rtl' : 'ltr'}"
|
||||||
|
style="text-align: ${isRTL ? 'right' : 'left'}; min-height: 120px;"
|
||||||
|
>${postContent}</textarea>
|
||||||
|
${imagePrompt ? `
|
||||||
|
<div style="padding: 12px; font-size: 13px; color: #666; font-weight: 500; border-top: 1px solid rgba(0,0,0,0.05); border-bottom: 1px solid rgba(0,0,0,0.05);">
|
||||||
|
Image Generation Prompt (Midjourney / DALL-E):
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="lja-cb-image-prompt"
|
||||||
|
style="width: 100%; border: none; padding: 12px; background: rgba(0,0,0,0.02); resize: vertical; min-height: 80px; font-family: monospace; font-size: 12px; outline: none;"
|
||||||
|
readonly
|
||||||
|
>${imagePrompt}</textarea>
|
||||||
|
` : ''}
|
||||||
|
<div class="lja-cb-actions">
|
||||||
|
<button class="lja-cb-copy">📋 Copy Post</button>
|
||||||
|
${imagePrompt ? '<button class="lja-cb-copy-img">🎨 Copy Image Prompt</button>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
box.querySelector('.lja-cb-close').addEventListener('click', () => box.remove());
|
||||||
|
|
||||||
|
box.querySelector('.lja-cb-copy').addEventListener('click', function () {
|
||||||
|
const text = box.querySelector('.lja-cb-text').value;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
this.textContent = '✅ Post Copied!';
|
||||||
|
setTimeout(() => { this.textContent = '📋 Copy Post'; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyImgBtn = box.querySelector('.lja-cb-copy-img');
|
||||||
|
if (copyImgBtn) {
|
||||||
|
copyImgBtn.addEventListener('click', function () {
|
||||||
|
const text = box.querySelector('.lja-cb-image-prompt').value;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
this.textContent = '✅ Prompt Copied!';
|
||||||
|
setTimeout(() => { this.textContent = '🎨 Copy Image Prompt'; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionBar = findActionBar(postEl);
|
||||||
|
if (actionBar) {
|
||||||
|
actionBar.parentNode.insertBefore(box, actionBar.nextSibling);
|
||||||
|
} else {
|
||||||
|
postEl.appendChild(box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inject button into a single post ────────────────────────────────────
|
||||||
|
function injectButton(postEl) {
|
||||||
|
if (postEl.querySelector('.' + BUTTON_CLASS)) return; // Already injected
|
||||||
|
|
||||||
|
const actionBar = findActionBar(postEl);
|
||||||
|
if (!actionBar) return;
|
||||||
|
|
||||||
|
if (!extractPostText(postEl)) return; // Skip non-text posts silently
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = BUTTON_CLASS;
|
||||||
|
btn.innerHTML = '💬 Smart Comment';
|
||||||
|
btn.title = 'Generate an AI-powered comment for this post';
|
||||||
|
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
generateComment(postEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rewriteBtn = document.createElement('button');
|
||||||
|
rewriteBtn.className = 'lja-rewrite-btn';
|
||||||
|
rewriteBtn.innerHTML = '📝 Rewrite';
|
||||||
|
rewriteBtn.title = 'Rewrite this post in your own style and generate an image prompt';
|
||||||
|
|
||||||
|
// Add some inline styles for the rewrite button to differentiate it
|
||||||
|
rewriteBtn.style.background = 'linear-gradient(135deg, #FF8787 0%, #E03131 100%)';
|
||||||
|
rewriteBtn.style.color = 'white';
|
||||||
|
rewriteBtn.style.border = 'none';
|
||||||
|
rewriteBtn.style.borderRadius = '16px';
|
||||||
|
rewriteBtn.style.padding = '4px 12px';
|
||||||
|
rewriteBtn.style.fontSize = '12px';
|
||||||
|
rewriteBtn.style.fontWeight = '600';
|
||||||
|
rewriteBtn.style.cursor = 'pointer';
|
||||||
|
rewriteBtn.style.marginLeft = '8px';
|
||||||
|
rewriteBtn.style.display = 'flex';
|
||||||
|
rewriteBtn.style.alignItems = 'center';
|
||||||
|
rewriteBtn.style.gap = '4px';
|
||||||
|
|
||||||
|
rewriteBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
repurposePost(postEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
actionBar.appendChild(btn);
|
||||||
|
actionBar.appendChild(rewriteBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Process all posts on the page ───────────────────────────────────────
|
||||||
|
function processAllPosts() {
|
||||||
|
// Check both classic LinkedIn UI and the new React/hash-class UI
|
||||||
|
const posts = document.querySelectorAll(
|
||||||
|
'.feed-shared-update-v2, .occludable-update, [data-urn*="activity"], [role="listitem"]'
|
||||||
|
);
|
||||||
|
posts.forEach(injectButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MutationObserver: watch for new posts (infinite scroll) ─────────────
|
||||||
|
let observerTimer;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
// Throttle the DOM scanning to prevent heavy performance hits on React updates
|
||||||
|
if (observerTimer) return;
|
||||||
|
observerTimer = setTimeout(() => {
|
||||||
|
processAllPosts();
|
||||||
|
observerTimer = null;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Initialize ──────────────────────────────────────────────────────────
|
||||||
|
function init() {
|
||||||
|
processAllPosts();
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
setTimeout(init, 2000); // Fallback for delayed SPA renders
|
||||||
|
}
|
||||||
|
})();
|
||||||
78
profile.js
78
profile.js
@@ -1,64 +1,62 @@
|
|||||||
// profile.js — Default user profile (pre-filled)
|
// profile.js — Default user profile (pre-filled)
|
||||||
// This is loaded into the popup and used as the default profile text.
|
// This is loaded into the popup and used as the default profile text.
|
||||||
|
|
||||||
const DEFAULT_PROFILE = `HAMZA AYED — Solutions Architect | Senior Flutter Developer | GIS & Mapping Expert
|
const DEFAULT_PROFILE = `HAMZA AYED — Senior Backend Engineer & Systems Architect
|
||||||
|
|
||||||
SUMMARY:
|
SUMMARY:
|
||||||
Solutions Architect with 6+ years building complete mobile ecosystems. Built two ride-hailing platforms (Egypt & Syria) with 4,300+ users. Created proprietary mapping infrastructure on OpenStreetMap, custom payment systems, and AI-powered verification. 30+ production apps on Google Play and App Store. Retired Lieutenant Colonel - مقدم (20 years Jordan Armed Forces).
|
Senior Backend Engineer and Systems Architect with experience building real-time transportation, mapping, and SaaS platforms across the MENA region. Specialized in high-concurrency PHP systems, distributed backend services, GIS infrastructure, and scalable APIs using Workerman, NestJS, Redis, PostgreSQL/PostGIS, Docker, and AWS. Built proprietary routing and mapping infrastructure integrating OpenStreetMap and GraphHopper for production ride-hailing environments.
|
||||||
|
|
||||||
CORE SKILLS:
|
TECHNICAL SKILLS:
|
||||||
- Architecture: System Design, API Architecture, Database Design, Cloud Infrastructure, Security Architecture
|
- Backend & Architecture: PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), REST APIs, WebSockets, Event-Driven Architecture
|
||||||
- Mobile: Flutter/Dart (Expert), 30+ production apps, GetX/Bloc, Clean Architecture, Offline-first, OTA updates (Shorebird)
|
- Infrastructure & Cloud: Docker, Linux, Nginx, Redis, CI/CD (GitHub Actions), AWS
|
||||||
- Backend: PHP (production), FastAPI, Flask, NestJS, Django, MySQL, PostgreSQL, Firebase
|
- Databases & Geospatial: PostgreSQL/PostGIS, MySQL, OpenStreetMap (OSM), GraphHopper, MapLibre GL, Spatial Queries
|
||||||
- GIS/Mapping: OpenStreetMap, Custom Tile Server, Mapping SDK (Flutter + JS, published on pub.dev & NPM), Geocoding, Route Optimization
|
- Mobile: Flutter/Dart, GetX, BLoC/Cubit
|
||||||
- FinTech: Stripe, PayPal, Paymob, Custom Payment Gateway, Digital Wallets, Driver Payout Systems
|
|
||||||
- AI/ML: Gemini Vision Models, Invoice Processing, KYC Verification, Document Analysis, Chatbot Systems
|
|
||||||
- Security: 3-layer Encryption, freeRASP, SSL/TLS Hardening, Server Hardening, Intrusion Monitoring
|
|
||||||
- DevOps: Linux Server Admin, CI/CD pipelines, Git, Nginx, Load Balancing, Shorebird Code Push
|
|
||||||
|
|
||||||
KEY ACHIEVEMENTS:
|
KEY ACHIEVEMENTS:
|
||||||
- Built IntaleqMaps: proprietary mapping platform on OSM, saving $800–$30,000/month vs Google Maps API
|
- Architected IntaleqMaps orchestrating GraphHopper routing, OSM tile serving, and PostGIS spatial queries via a NestJS API layer, saving $10,000+/month vs Google Maps API
|
||||||
- Created custom Flutter Mapping SDK published on pub.dev and NPM
|
- Created custom Flutter Mapping SDK (intaleq_maps) published on pub.dev and JS Library (intaleq-maps-gl) on NPM
|
||||||
- Designed payment infrastructure for Syria where no standard payment APIs exist
|
- Designed and built the IntaleqMaps platform using NestJS and PostGIS, and core ecosystem backend using PHP/Workerman
|
||||||
|
- Designed custom FinTech payment infrastructure for markets with zero standard payment API access
|
||||||
- Built 4-app ecosystem: Rider App, Driver App, Admin Dashboard, Service Portal — all from scratch
|
- Built 4-app ecosystem: Rider App, Driver App, Admin Dashboard, Service Portal — all from scratch
|
||||||
|
- Architected smart mobility platform serving 1,800+ drivers and 2,500+ riders across MENA
|
||||||
- Integrated AI vision models for invoice processing (Musadaq platform)
|
- Integrated AI vision models for invoice processing (Musadaq platform)
|
||||||
- Built AI-Powered ATS Automation Chrome Extension: Real-time LinkedIn DOM scraping, Gemini AI integration, and PHP-based PDF generation
|
- Developed Nabih: AI-powered smart responder system orchestrating a Flutter application (GetX/Cubit) with a PHP backend API for automated customer query resolution
|
||||||
|
- Built AI-Powered ATS Automation Chrome Extension: Real-time LinkedIn DOM scraping, Gemini 1.5 Pro, PHP-based PDF generation
|
||||||
- 51 professional certifications (Google, IBM, Meta via Coursera)
|
- 51 professional certifications (Google, IBM, Meta via Coursera)
|
||||||
- $0.78 Driver Customer Acquisition Cost in the competitive Egyptian market
|
- $0.78 Driver Customer Acquisition Cost in the competitive Egyptian market
|
||||||
- Government-licensed (NANS accreditation) — only licensed platform in Syrian market
|
- Government-accredited — only licensed mobility platform in its market
|
||||||
|
|
||||||
EXPERIENCE:
|
EXPERIENCE:
|
||||||
- CTO & Solutions Architect — Intaleq | Jan 2024–Present | Syria/Remote
|
- CTO & Technical Architect | Systems & Backend Focus — Intaleq | Jan 2024–Present | Remote (MENA Region)
|
||||||
→ Led full-stack architecture of smart transportation ecosystem: 1,800+ drivers, 2,500+ riders
|
→ Led full-stack architecture of smart transportation ecosystem: 1,800+ drivers, 2,500+ riders. Designed core backend in PHP/Workerman and mapping platform backend in NestJS. Created proprietary mapping, routing, and payment infrastructure.
|
||||||
- Co-Founder & Lead Developer — Tripz Egypt | Jan 2024–Present | Cairo/Remote
|
- Founding Engineer & Lead Backend Architect — Tripz Egypt | Jan 2023–Present | Cairo/Remote
|
||||||
→ Built Egypt's homegrown ride-hailing platform: 8 ride types, 8% driver commission (lowest in market)
|
→ Built ride-hailing platform from zero: 8 ride types, 8% driver commission (lowest in market). Full-stack: Flutter apps, PHP backend, WebSockets, event-driven architecture, payment integrations. $0.78 CAC.
|
||||||
- Mobile Solutions Architect — Freelance | Jan 2017–Dec 2023 | Jordan/Remote
|
- Senior Systems Architect — Mobile & Backend (Freelance) | Jan 2017–Dec 2023 | Jordan/Remote
|
||||||
→ 25+ production apps across MENA clients. (Note: Done concurrently with military service).
|
→ 25+ production apps across MENA clients spanning healthcare, logistics, sports, HR, and utilities.
|
||||||
- Operations & Logistics Officer — Jordan Armed Forces | Oct 2003–Nov 2023
|
|
||||||
→ Retired Lieutenant Colonel (مقدم). Led 20–50+ personnel. Crisis management, logistics, security clearance.
|
|
||||||
|
|
||||||
EDUCATION:
|
EDUCATION:
|
||||||
- BS Mathematics, Mutah University, 2003–2007 (Very Good)
|
- BS Mathematics (Applied Computing & Algorithms), Mutah University, 2003–2007 (Very Good)
|
||||||
- Google Data Analytics Professional Certificate
|
- Google Data Analytics Professional Certificate (Coursera)
|
||||||
- IBM Data Science Professional Certificate
|
- IBM Data Analyst Professional Certificate (Coursera)
|
||||||
- Meta Mobile Development Certificate
|
- Meta APIs & Django Web Framework Course Certifications (Coursera)
|
||||||
- 51 total certifications
|
- AWS Cloud Practitioner & Cloud Architecture Fundamentals
|
||||||
|
|
||||||
LANGUAGES: Arabic (Native), English (Professional Working Proficiency)
|
LANGUAGES: Arabic (Native), English (Professional Working Proficiency)
|
||||||
LOCATION: Amman, Jordan | Open to Remote, Hybrid, On-site (Middle East & Gulf)
|
LOCATION: Amman, Jordan | Open to Remote, Hybrid, On-site (Middle East & Gulf)
|
||||||
CONTACT: hamzaayed@intaleqapp.com | +962 79 858 3052 | linkedin.com/in/hamza-ayed
|
CONTACT: hamzaayed.dev@gmail.com | +962 79 858 3052 | linkedin.com/in/hamza-ayed | github.com/Hamza-Ayed | intaleqapp.com/hamza.html (Portfolio)
|
||||||
|
|
||||||
TARGET ROLES:
|
TARGET ROLES:
|
||||||
1. Solutions Architect (systems, GIS, FinTech focus)
|
1. Technical Architect / Solutions Architect (distributed systems, cloud, GIS, FinTech)
|
||||||
2. Senior Flutter Developer (30+ apps, mobile ecosystems)
|
2. Lead Software Engineer (full-stack, mobile ecosystems, backend)
|
||||||
3. GIS / Geospatial Developer (mapping, OSM, custom SDK)
|
3. GIS / Geospatial Developer (mapping, OSM, custom SDK, spatial data)
|
||||||
4. FinTech Integration Engineer (payments, wallets, gateways)
|
4. FinTech Integration Engineer (payments, wallets, gateways)
|
||||||
5. Technical Consultant / Mobile Architecture Lead
|
5. Engineering Manager / Mobile Architecture Lead
|
||||||
|
|
||||||
NOTABLE PROJECTS:
|
NOTABLE PROJECTS:
|
||||||
- Intaleq Smart Mobility Platform (intaleqapp.com)
|
- Intaleq Smart Mobility Platform — Real-time ride-hailing with PHP/Workerman core backend, NestJS mapping engine, PostGIS spatial features, and proprietary payment infrastructure
|
||||||
- Tripz Egypt Ride-hailing (tripz-egypt.com)
|
- Tripz Egypt — Ride-hailing platform with 8 ride types, AI-powered KYC, lowest commission model, WebSockets dispatcher
|
||||||
- Musadaq — AI invoice processing platform
|
- Musadaq — AI-powered invoice processing platform with vision models
|
||||||
- IntaleqMaps SDK — pub.dev + NPM published
|
- Nabih — AI-powered smart responder assistant built with Flutter (GetX/Cubit) and PHP backend APIs
|
||||||
- LinkedIn Job Analyzer: AI-powered Chrome extension with real-time DOM scraping, Gemini 1.5 Pro integration, and PHP backend ATS CV generation.
|
- IntaleqMaps SDK & JS GL — Published mapping libraries on pub.dev + NPM (https://pub.dev/packages/intaleq_maps and https://libraries.io/npm/intaleq-maps-gl)
|
||||||
- 25+ client apps: sports news, HR systems, transit, utilities`;
|
- LinkedIn Job Analyzer — AI Chrome extension with real-time DOM scraping, Gemini 1.5 Pro, PHP PDF generation
|
||||||
|
- 25+ client apps: healthcare, sports news, HR systems, transit, utilities`;
|
||||||
|
|||||||
60
prompts.js
60
prompts.js
@@ -2,10 +2,8 @@
|
|||||||
// LANGUAGE RULES: Analysis = match job language. Everything else = ENGLISH ALWAYS.
|
// LANGUAGE RULES: Analysis = match job language. Everything else = ENGLISH ALWAYS.
|
||||||
|
|
||||||
function buildPromptV2(tab, job, userProfile, language) {
|
function buildPromptV2(tab, job, userProfile, language) {
|
||||||
// Only analysis follows job language; rest is always English
|
// Force Arabic for the analysis tab to ensure full comprehension
|
||||||
const analysisLang = language === 'arabic' ? 'Respond entirely in Arabic.'
|
const analysisLang = 'Respond ENTIRELY in Arabic. All headings, explanations, and bullets MUST be in Arabic. and RTL';
|
||||||
: language === 'english' ? 'Respond entirely in English.'
|
|
||||||
: 'Respond in the same language as the job posting.';
|
|
||||||
|
|
||||||
const ctx = [
|
const ctx = [
|
||||||
'Job Title: ' + (job.jobTitle || 'Not specified'),
|
'Job Title: ' + (job.jobTitle || 'Not specified'),
|
||||||
@@ -20,12 +18,23 @@ function buildPromptV2(tab, job, userProfile, language) {
|
|||||||
const co = job.company || 'this company';
|
const co = job.company || 'this company';
|
||||||
const loc = job.location || 'Middle East';
|
const loc = job.location || 'Middle East';
|
||||||
|
|
||||||
|
const STRICT_RULES = `
|
||||||
|
STRICT INTEGRITY RULES — VIOLATING THESE IS FORBIDDEN:
|
||||||
|
1. NEVER invent skills, tools, frameworks, or certifications not explicitly listed in MY PROFESSIONAL PROFILE.
|
||||||
|
2. If the job requires a skill I do not have (e.g., TensorFlow, PyTorch, Scikit-learn, Spark, Hadoop, Kubernetes, deep NLP, generative AI model training), ACKNOWLEDGE THE GAP honestly. Do NOT fabricate experience.
|
||||||
|
3. My AI experience is LIMITED to: AI vision models for document processing (Musadaq), AI smart responder (Nabih), Gemini API integration (LinkedIn extension), and Python backend automation. I do NOT have MLOps pipeline experience, model training, or deep learning research.
|
||||||
|
4. My TRUE core stack is: PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), PostgreSQL/PostGIS, Docker, Flutter — NOT data science or ML engineering.
|
||||||
|
5. Always prioritize my REAL architecture achievements (IntaleqMaps, Tripz, 25+ apps, $10K/month savings) over generic AI buzzwords.`;
|
||||||
|
|
||||||
const P = {};
|
const P = {};
|
||||||
|
|
||||||
P.analysis = `You are an elite career strategist.
|
P.analysis = `You are an elite career strategist.
|
||||||
${analysisLang}
|
${analysisLang}
|
||||||
Evaluate this job against my profile with brutal honesty and EXTREME brevity.
|
Evaluate this job against my profile with brutal honesty and EXTREME brevity.
|
||||||
DO NOT recount my history, military background, or summarize my profile. Keep it actionable and short.
|
DO NOT recount my history, military background, or summarize my profile. Keep it actionable and short.
|
||||||
|
CRITICAL RULE: The user is a Senior Backend Engineer and Technical Lead who built and scaled production systems from zero. Position his 0-to-1, hands-on delivery experience as a massive advantage for any engineering team. Do NOT downgrade his technical leadership.
|
||||||
|
|
||||||
|
${STRICT_RULES}
|
||||||
|
|
||||||
${prof}
|
${prof}
|
||||||
|
|
||||||
@@ -53,6 +62,8 @@ Respond in this EXACT concise structure:
|
|||||||
P.coverletter = `You are an expert career writer. Write a COMPLETE, READY-TO-SEND cover letter.
|
P.coverletter = `You are an expert career writer. Write a COMPLETE, READY-TO-SEND cover letter.
|
||||||
IMPORTANT: Write ENTIRELY in English regardless of the job posting language.
|
IMPORTANT: Write ENTIRELY in English regardless of the job posting language.
|
||||||
|
|
||||||
|
${STRICT_RULES}
|
||||||
|
|
||||||
${prof}
|
${prof}
|
||||||
|
|
||||||
JOB:
|
JOB:
|
||||||
@@ -70,18 +81,21 @@ Dear ${co} Team,
|
|||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
Hamza Ayed
|
Hamza Ayed
|
||||||
hamzaayed@intaleqapp.com
|
hamzaayed.dev@gmail.com
|
||||||
+962 79 858 3052
|
+962 79 858 3052
|
||||||
|
|
||||||
CRITICAL RULES:
|
CRITICAL RULES:
|
||||||
- MUST be in English
|
- MUST be in English
|
||||||
- NO brackets or placeholders — use ACTUAL names and data
|
- NO brackets or placeholders — use ACTUAL names and data
|
||||||
- Every sentence specific to THIS job at ${co}
|
- Every sentence specific to THIS job at ${co}
|
||||||
- Under 300 words total`;
|
- Under 300 words total
|
||||||
|
- Tone MUST be professional, direct, and confident. Focus on delivery, impact, and hands-on engineering. DO NOT mention military history. DO NOT use buzzwords like "sanctioned markets", "serving millions", "disruptive".`;
|
||||||
|
|
||||||
P.cvtips = `You are a LinkedIn optimization expert and ATS specialist.
|
P.cvtips = `You are a LinkedIn optimization expert and ATS specialist.
|
||||||
IMPORTANT: Respond ENTIRELY in English regardless of the job posting language.
|
IMPORTANT: Respond ENTIRELY in English regardless of the job posting language.
|
||||||
|
|
||||||
|
${STRICT_RULES}
|
||||||
|
|
||||||
${prof}
|
${prof}
|
||||||
|
|
||||||
JOB:
|
JOB:
|
||||||
@@ -90,10 +104,10 @@ ${ctx}
|
|||||||
Respond EXACTLY:
|
Respond EXACTLY:
|
||||||
|
|
||||||
## LINKEDIN HEADLINE
|
## LINKEDIN HEADLINE
|
||||||
[One headline, max 120 chars, ATS-optimized for this role]
|
[One headline, EXACTLY under 80 characters, ATS-optimized. Format like: Senior Backend Engineer & Technical Lead | PHP, NestJS, Docker | Building for Scale]
|
||||||
|
|
||||||
## PROFESSIONAL SUMMARY
|
## PROFESSIONAL SUMMARY
|
||||||
[3-4 sentences tailored summary ready to paste — in English]
|
[3-4 sentences tailored summary ready to paste — in English. CRITICAL: Focus on delivery, cost optimization, and hands-on engineering. Use a professional, direct, no-fluff tone. Emphasize impact: what you built, numbers you achieved, problems you solved. DO NOT use academic or AI-generated phrases like 'I leverage my expertise'. DO NOT invent industries like 'banking' unless they are in MY PROFILE.]
|
||||||
|
|
||||||
## ATS KEYWORDS TO ADD
|
## ATS KEYWORDS TO ADD
|
||||||
- [10-15 specific keywords from this job that MUST appear in CV]
|
- [10-15 specific keywords from this job that MUST appear in CV]
|
||||||
@@ -112,7 +126,7 @@ Respond EXACTLY:
|
|||||||
## NEW BULLET POINTS TO ADD
|
## NEW BULLET POINTS TO ADD
|
||||||
- [2-3 new achievement bullets ready to paste into CV — in English]`;
|
- [2-3 new achievement bullets ready to paste into CV — in English]`;
|
||||||
|
|
||||||
const dynamicQuestions = job.questions && job.questions.length > 0
|
const dynamicQuestions = job.questions && job.questions.length > 0
|
||||||
? job.questions.map((q, i) => `${i + 1}. "${q.question}" [type: ${q.type}]`).join('\n')
|
? job.questions.map((q, i) => `${i + 1}. "${q.question}" [type: ${q.type}]`).join('\n')
|
||||||
: '1. "Why are you interested in this role?" [type: text]\n2. "What is your relevant experience?" [type: text]\n3. "What are your salary expectations?" [type: text]\n4. "When can you start?" [type: text]\n5. "Do you require visa sponsorship?" [type: text]';
|
: '1. "Why are you interested in this role?" [type: text]\n2. "What is your relevant experience?" [type: text]\n3. "What are your salary expectations?" [type: text]\n4. "When can you start?" [type: text]\n5. "Do you require visa sponsorship?" [type: text]';
|
||||||
|
|
||||||
@@ -125,10 +139,16 @@ STRICT RULES:
|
|||||||
- Do NOT use code blocks like \`\`\`json.
|
- Do NOT use code blocks like \`\`\`json.
|
||||||
- For number/numeric questions: answer with JUST a number (e.g. "6").
|
- For number/numeric questions: answer with JUST a number (e.g. "6").
|
||||||
- For yes/no or select questions: answer with JUST "Yes" or "No".
|
- For yes/no or select questions: answer with JUST "Yes" or "No".
|
||||||
- For salary questions: answer with JUST a number (e.g. "25000").
|
- For salary questions: Output ONLY a number. IMPORTANT: Adapt the number intelligently to the job's likely currency (e.g., if UAE/Saudi: ~30000 AED/SAR. If remote USD: ~6000 USD).
|
||||||
- For text questions: answer in 1 short sentence max.
|
- For text questions: answer in 1 short sentence max.
|
||||||
- Keys must be the EXACT question text.
|
- Keys must be the EXACT question text.
|
||||||
|
|
||||||
|
MY PERSONAL DETAILS & PREFERENCES (Use these for answers):
|
||||||
|
- Location: Amman, Jordan
|
||||||
|
- Phone / Country Code: +962
|
||||||
|
- Notice Period / Start Date: Available Immediately
|
||||||
|
- Salary Expectations: Competitive / Negotiable (If forced to give a number, calculate based on the job's currency as instructed above).
|
||||||
|
|
||||||
MY PROFILE:
|
MY PROFILE:
|
||||||
${userProfile}
|
${userProfile}
|
||||||
|
|
||||||
@@ -138,10 +158,10 @@ ${ctx}
|
|||||||
QUESTIONS TO ANSWER:
|
QUESTIONS TO ANSWER:
|
||||||
${dynamicQuestions}
|
${dynamicQuestions}
|
||||||
|
|
||||||
RESPOND WITH ONLY THIS FORMAT (raw JSON, no wrapping):
|
RESPOND WITH ONLY THIS FORMAT (raw JSON, no wrapping, answering ONLY the questions listed above):
|
||||||
{
|
{
|
||||||
"exact question text here": "concise answer",
|
"question 1 text here": "concise answer",
|
||||||
"another question": "answer"
|
"question 2 text here": "concise answer"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
P.benefits = `You are a career analyst specializing in tech compensation in MENA.
|
P.benefits = `You are a career analyst specializing in tech compensation in MENA.
|
||||||
@@ -177,6 +197,20 @@ Respond EXACTLY:
|
|||||||
## OVERALL RATING: X/10
|
## OVERALL RATING: X/10
|
||||||
**Worth applying?** [YES / MAYBE / NO]
|
**Worth applying?** [YES / MAYBE / NO]
|
||||||
[2-3 sentence honest assessment]`;
|
[2-3 sentence honest assessment]`;
|
||||||
|
P.list_analysis = `You are an AI pre-screening jobs.
|
||||||
|
I will give you a JSON array of jobs (Title, Company).
|
||||||
|
My stack: Flutter, Python (FastAPI), PHP, Node.js, GIS, Technical Architect.
|
||||||
|
I am actively seeking Senior Engineer, Tech Lead, or Architect roles.
|
||||||
|
Reject Java, C#, C++, .NET, or pure Product Management roles.
|
||||||
|
|
||||||
|
JOBS LIST:
|
||||||
|
${job.listData}
|
||||||
|
|
||||||
|
Respond ONLY with a raw JSON array of objects, one for each job, containing:
|
||||||
|
[
|
||||||
|
{ "index": number, "verdict": "YES" | "NO" | "MAYBE", "reason": "Short reason" }
|
||||||
|
]
|
||||||
|
Do not wrap in markdown \`\`\`json blocks.`;
|
||||||
|
|
||||||
return P[tab] || P.analysis;
|
return P[tab] || P.analysis;
|
||||||
}
|
}
|
||||||
|
|||||||
120
search_analyzer.css
Normal file
120
search_analyzer.css
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/* search_analyzer.css — Styles for LinkedIn Investor Scanner */
|
||||||
|
|
||||||
|
.lja-scan-list-btn {
|
||||||
|
background: linear-gradient(135deg, #6C63FF 0%, #4834d4 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 10px 0 20px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(108, 99, 255, 0.3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-scan-list-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(108, 99, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-scan-list-btn:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-scan-person-btn {
|
||||||
|
background: #f0f0f5;
|
||||||
|
color: #6C63FF;
|
||||||
|
border: 1px solid #dcdce6;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 10px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-scan-person-btn:hover {
|
||||||
|
background: #e6e6ff;
|
||||||
|
border-color: #6C63FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-scan-person-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-investor-result {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
animation: ljaFadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-investor-result.green {
|
||||||
|
background-color: rgba(0, 214, 126, 0.1);
|
||||||
|
border-left: 4px solid #00d67e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-investor-result.red {
|
||||||
|
background-color: rgba(255, 107, 107, 0.1);
|
||||||
|
border-left: 4px solid #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-investor-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-investor-badge.green {
|
||||||
|
color: #00b368;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-investor-badge.red {
|
||||||
|
color: #e03131;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-investor-reason {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #fff;
|
||||||
|
animation: ljaSpin 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lja-scan-person-btn .lja-spinner {
|
||||||
|
border-color: rgba(108,99,255,0.3);
|
||||||
|
border-top-color: #6C63FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ljaFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ljaSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
387
search_analyzer.js
Normal file
387
search_analyzer.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
// search_analyzer.js — LinkedIn People Search Investor Analyzer
|
||||||
|
// Operates on linkedin.com/search/results/people/*
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
console.log('[LJA] Search Analyzer Script Loaded');
|
||||||
|
|
||||||
|
// Prevent double injection
|
||||||
|
if (window.__linkedinSearchAnalyzerLoaded) return;
|
||||||
|
window.__linkedinSearchAnalyzerLoaded = true;
|
||||||
|
|
||||||
|
// ─── Utility: get stored settings ────────────────────────────────────────
|
||||||
|
function getSettings() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!chrome || !chrome.storage) {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.storage.sync.get(['apiKey', 'language', 'userProfile'], (syncData) => {
|
||||||
|
if (syncData && syncData.apiKey) {
|
||||||
|
resolve(syncData);
|
||||||
|
} else {
|
||||||
|
chrome.storage.local.get(['apiKey', 'language', 'userProfile'], (localData) => {
|
||||||
|
resolve(localData || {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Find Result Container ───────────────────────────────────────────────
|
||||||
|
function getSearchResultsContainer() {
|
||||||
|
return document.querySelector('.search-results-container, .reusable-search__entity-result-list, ul.reusable-search__entity-result-list, .search-results__list');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Extract Person Data ─────────────────────────────────────────────────
|
||||||
|
function extractPersonData(cardEl) {
|
||||||
|
const data = {
|
||||||
|
name: '',
|
||||||
|
headline: '',
|
||||||
|
location: '',
|
||||||
|
summary: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get all text lines, filter out empty ones
|
||||||
|
const rawLines = cardEl.innerText.split('\n').map(s => s.trim()).filter(s => s.length > 1);
|
||||||
|
|
||||||
|
// 2. Filter out known noise (translation extensions, action buttons, connection degrees, etc)
|
||||||
|
const lines = rawLines.filter(s => {
|
||||||
|
const low = s.toLowerCase();
|
||||||
|
// Remove degree connections
|
||||||
|
if (low.includes('degree connection')) return false;
|
||||||
|
if (low.includes('• 1st') || low.includes('• 2nd') || low.includes('• 3rd')) return false;
|
||||||
|
if (['1st', '2nd', '3rd', '3rd+'].includes(low)) return false;
|
||||||
|
// Remove translation artifacts
|
||||||
|
if (low.includes('english (australia)') || low === 'auto' || low === 'translate') return false;
|
||||||
|
// Remove action buttons (exact match to avoid removing headlines like "Connect with me")
|
||||||
|
if (low === 'connect' || low === 'message' || low === 'pending' || low === 'follow' || low === 'view profile') return false;
|
||||||
|
// Remove mutual connections line
|
||||||
|
if (low.includes('mutual connection')) return false;
|
||||||
|
// Remove injected button text from this extension
|
||||||
|
if (low.includes('scan investor')) return false;
|
||||||
|
if (low.includes('تجاهله') || low.includes('تواصل معه') || low.includes('error')) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Assign the cleaned lines
|
||||||
|
// Remove duplicates if the name appears twice (e.g., "Hamza" then "Hamza • 2nd" filtered to "Hamza")
|
||||||
|
let uniqueLines = [];
|
||||||
|
lines.forEach(l => { if (!uniqueLines.includes(l)) uniqueLines.push(l); });
|
||||||
|
|
||||||
|
if (uniqueLines.length > 0) data.name = uniqueLines[0];
|
||||||
|
if (uniqueLines.length > 1) data.headline = uniqueLines[1];
|
||||||
|
if (uniqueLines.length > 2) data.location = uniqueLines[2];
|
||||||
|
|
||||||
|
// Everything else is summary
|
||||||
|
if (uniqueLines.length > 3) {
|
||||||
|
data.summary = uniqueLines.slice(3, 8).join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LJA] Extraction failed', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.name || data.name.length < 2) data.name = 'مستثمر محتمل';
|
||||||
|
if (!data.headline) data.headline = 'لا يوجد مسمى وظيفي';
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Find Cards Logic ──────────────────────────────────────────────────────
|
||||||
|
function findCards() {
|
||||||
|
let uniqueCards = new Set();
|
||||||
|
|
||||||
|
// Find the main Action Button (Connect/Message/Follow) which every profile card has
|
||||||
|
let allButtons = Array.from(document.querySelectorAll('button, a'));
|
||||||
|
|
||||||
|
let actionElements = allButtons.filter(el => {
|
||||||
|
if (!el.innerText) return false;
|
||||||
|
let txt = el.innerText.toLowerCase().trim();
|
||||||
|
if (txt.length === 0 || txt.length > 20) return false;
|
||||||
|
// Exact match ONLY: avoid "8K followers" matching "follow"
|
||||||
|
return txt === 'connect' || txt === 'message' || txt === 'pending' || txt === 'follow' || txt === '+ connect';
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[LJA] Exact action buttons found:', actionElements.length, actionElements.map(e => e.innerText.trim()));
|
||||||
|
|
||||||
|
actionElements.forEach(btn => {
|
||||||
|
// Go up and find the FIRST/SMALLEST ancestor with enough text to be a profile card
|
||||||
|
let container = btn.parentElement;
|
||||||
|
for(let i=0; i<25; i++) {
|
||||||
|
if (!container || container === document.body) break;
|
||||||
|
const txt = container.innerText;
|
||||||
|
// A profile card has at least 100 chars (name + headline + location + button)
|
||||||
|
// and not too many (< 3000 so we don't grab the whole results list)
|
||||||
|
if (txt && txt.length > 100 && txt.length < 3000) {
|
||||||
|
uniqueCards.add(container);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
container = container.parentElement;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cards = Array.from(uniqueCards);
|
||||||
|
console.log('[LJA] Found valid profile cards:', cards.length);
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inject UI into Card ─────────────────────────────────────────────────
|
||||||
|
function injectScanButton(cardEl) {
|
||||||
|
if (cardEl.querySelector('.lja-scan-person-btn') || cardEl.querySelector('.lja-investor-result')) return;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'lja-scan-person-btn';
|
||||||
|
btn.innerHTML = '🔍 Scan Investor';
|
||||||
|
|
||||||
|
btn.style.margin = '10px 0';
|
||||||
|
btn.style.padding = '5px 15px';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.style.backgroundColor = '#6C63FF';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.border = 'none';
|
||||||
|
btn.style.borderRadius = '5px';
|
||||||
|
btn.style.fontWeight = 'bold';
|
||||||
|
btn.style.zIndex = '999';
|
||||||
|
btn.style.position = 'relative';
|
||||||
|
|
||||||
|
const resultContainer = document.createElement('div');
|
||||||
|
resultContainer.className = 'lja-result-wrapper';
|
||||||
|
resultContainer.style.width = '100%';
|
||||||
|
resultContainer.style.marginTop = '10px';
|
||||||
|
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
await scanPerson(cardEl, btn, resultContainer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to append to actions area
|
||||||
|
const actionArea = cardEl.querySelector('.entity-result__actions, .search-result__actions');
|
||||||
|
if (actionArea) {
|
||||||
|
actionArea.prepend(btn);
|
||||||
|
actionArea.appendChild(resultContainer);
|
||||||
|
} else {
|
||||||
|
// Fallback: Find the name link and put it under it
|
||||||
|
const profileLink = Array.from(cardEl.querySelectorAll('a[href*="/in/"]')).find(a => a.innerText.trim().length > 0);
|
||||||
|
if (profileLink && profileLink.parentElement) {
|
||||||
|
profileLink.parentElement.appendChild(btn);
|
||||||
|
profileLink.parentElement.appendChild(resultContainer);
|
||||||
|
} else {
|
||||||
|
cardEl.appendChild(btn);
|
||||||
|
cardEl.appendChild(resultContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[LJA] Injected button for a profile:', extractPersonData(cardEl).name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scan a Single Person ────────────────────────────────────────────────
|
||||||
|
async function scanPerson(cardEl, btnEl, resultContainer) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings || !settings.apiKey) {
|
||||||
|
alert('Please set your Gemini API key in the extension popup first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = extractPersonData(cardEl);
|
||||||
|
if (!data.name && !data.headline) {
|
||||||
|
console.error('[LJA] Could not extract details, skipping.');
|
||||||
|
btnEl.innerHTML = '❌ Extraction Failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnEl.disabled = true;
|
||||||
|
btnEl.innerHTML = '<span class="lja-spinner"></span> Scanning...';
|
||||||
|
|
||||||
|
const prompt = `أنت مستشار استثماري ذكي وصارم جداً في تقييم المستثمرين للشركات الناشئة في الشرق الأوسط.
|
||||||
|
المستخدم يبحث عن مستثمرين (Angel Investors) أو شركاء لتمويل تطبيقاته "انطلق" (Intaleq) و "تريبز" (Tripz) (تطبيقات نقل ذكي Ride-hailing).
|
||||||
|
|
||||||
|
البيانات المستخرجة (من صفحة البحث فقط):
|
||||||
|
الاسم: ${data.name}
|
||||||
|
المسمى الوظيفي: ${data.headline}
|
||||||
|
الموقع: ${data.location}
|
||||||
|
نبذة/تاريخ: ${data.summary}
|
||||||
|
|
||||||
|
مهمتك: التقييم الصارم والتدقيق. الكثير من الأشخاص يكتبون "Angel Investor" الوهمية.
|
||||||
|
قواعد التقييم:
|
||||||
|
1. (green) تواصل معه: فقط إذا كان يمتلك منصباً قيادياً حقيقياً (CEO, Founder, Director) في شركة معروفة، أو يعمل في صندوق استثماري (VC)، أو لديه خبرة واضحة تدل على ملاءة مالية (مثل مسؤول سابق في بنك أو شركة كبرى).
|
||||||
|
2. (red) تجاهله: إذا كان مجرد موظف عادي، أو يكتب "Angel Investor" عند "Self-employed" بدون تاريخ مهني قوي، أو يبدو كشخص مبتدئ لا يمتلك القدرة المالية لتمويل تطبيق بحجم أوبر.
|
||||||
|
|
||||||
|
يجب أن يكون الرد بصيغة JSON فقط بهذا الشكل:
|
||||||
|
{
|
||||||
|
"status": "green" أو "red",
|
||||||
|
"reason": "سبب التقييم (كن صريحاً وقاسياً إذا كان الشخص يبدو مدعياً، سطر واحد فقط)"
|
||||||
|
}
|
||||||
|
لا تقم بإضافة أي نص آخر.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'GEMINI_REQUEST',
|
||||||
|
payload: {
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
action: 'generateText',
|
||||||
|
prompt: prompt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawText = response.data.text || response.data;
|
||||||
|
rawText = rawText.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||||||
|
|
||||||
|
let resultData;
|
||||||
|
try {
|
||||||
|
resultData = JSON.parse(rawText);
|
||||||
|
} catch (parseError) {
|
||||||
|
throw new Error('Failed to parse AI response. Raw: ' + rawText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGreen = resultData.status === 'green';
|
||||||
|
const badgeIcon = isGreen ? '✅' : '❌';
|
||||||
|
const badgeText = isGreen ? 'تواصل معه' : 'تجاهله';
|
||||||
|
const colorClass = isGreen ? 'green' : 'red';
|
||||||
|
|
||||||
|
resultContainer.innerHTML = `
|
||||||
|
<div class="lja-investor-result ${colorClass}" dir="rtl" style="margin-top: 10px; padding: 10px; border-radius: 8px; font-weight: bold; font-family: system-ui; background-color: ${isGreen ? '#e6ffe6' : '#ffe6e6'}; color: ${isGreen ? '#006600' : '#cc0000'}; border: 1px solid ${isGreen ? '#00cc00' : '#ff0000'};">
|
||||||
|
<div class="lja-investor-badge">
|
||||||
|
<span>${badgeIcon}</span> ${badgeText}
|
||||||
|
</div>
|
||||||
|
<div class="lja-investor-reason" style="margin-top: 5px; font-weight: normal; font-size: 14px;">
|
||||||
|
${resultData.reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
btnEl.style.display = 'none';
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LJA Search]', e);
|
||||||
|
resultContainer.innerHTML = `<div class="lja-investor-result red" style="color:red; font-weight:bold;">❌ Error: ${e.message}</div>`;
|
||||||
|
btnEl.disabled = false;
|
||||||
|
btnEl.innerHTML = '🔍 Scan Investor';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scan All Feature ────────────────────────────────────────────────────
|
||||||
|
function injectScanAllButton() {
|
||||||
|
if (document.querySelector('.lja-scan-list-btn')) return;
|
||||||
|
|
||||||
|
let container = getSearchResultsContainer();
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'lja-scan-list-btn';
|
||||||
|
btn.innerHTML = '✨ Scan All Investors';
|
||||||
|
btn.title = 'Scan all loaded profiles on this page';
|
||||||
|
|
||||||
|
btn.style.margin = '20px auto';
|
||||||
|
btn.style.display = 'block';
|
||||||
|
btn.style.padding = '10px 20px';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.style.backgroundColor = '#6C63FF';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.border = 'none';
|
||||||
|
btn.style.borderRadius = '8px';
|
||||||
|
btn.style.fontWeight = 'bold';
|
||||||
|
btn.style.fontSize = '16px';
|
||||||
|
btn.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)';
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const cards = findCards();
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
alert('No profiles found to scan.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="lja-spinner"></span> Scanning profiles...';
|
||||||
|
|
||||||
|
// Scan sequentially
|
||||||
|
for (const card of cards) {
|
||||||
|
const scanBtn = card.querySelector('.lja-scan-person-btn');
|
||||||
|
if (scanBtn && scanBtn.style.display !== 'none') {
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
scanBtn.click();
|
||||||
|
while (scanBtn.disabled && scanBtn.style.display !== 'none') {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '✨ Scan Complete!';
|
||||||
|
setTimeout(() => { btn.innerHTML = '✨ Scan All Investors'; }, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (container && container.parentNode) {
|
||||||
|
container.parentNode.insertBefore(btn, container);
|
||||||
|
} else {
|
||||||
|
// Fallback: Floating button bottom right
|
||||||
|
btn.style.position = 'fixed';
|
||||||
|
btn.style.bottom = '20px';
|
||||||
|
btn.style.right = '20px';
|
||||||
|
btn.style.zIndex = '99999';
|
||||||
|
btn.style.margin = '0';
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
}
|
||||||
|
console.log('[LJA] Injected Scan All button');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Process Page ────────────────────────────────────────────────────────
|
||||||
|
function processPage() {
|
||||||
|
if (!window.location.href.includes('linkedin.com/search/results/')) return;
|
||||||
|
|
||||||
|
console.log('[LJA] Attempting to find profile cards...');
|
||||||
|
const cards = findCards();
|
||||||
|
console.log('[LJA] processPage found cards:', cards.length);
|
||||||
|
|
||||||
|
if (cards.length > 0) {
|
||||||
|
injectScanAllButton();
|
||||||
|
cards.forEach(injectScanButton);
|
||||||
|
} else {
|
||||||
|
console.log('[LJA] No cards could be identified on this page.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MutationObserver: watch for new results (pagination/filters) ────────
|
||||||
|
let observerTimer;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (observerTimer) return;
|
||||||
|
observerTimer = setTimeout(() => {
|
||||||
|
processPage();
|
||||||
|
observerTimer = null;
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Initialize ──────────────────────────────────────────────────────────
|
||||||
|
function init() {
|
||||||
|
console.log('[LJA] Initializing Search Analyzer...');
|
||||||
|
processPage();
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SPA navigation by checking URL changes
|
||||||
|
let lastUrl = window.location.href;
|
||||||
|
setInterval(() => {
|
||||||
|
if (window.location.href !== lastUrl) {
|
||||||
|
lastUrl = window.location.href;
|
||||||
|
if (lastUrl.includes('linkedin.com/search/results/')) {
|
||||||
|
console.log('[LJA] SPA Navigation detected, re-processing...');
|
||||||
|
setTimeout(processPage, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
setTimeout(init, 3000); // Fallback for delayed renders
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -8,19 +8,23 @@
|
|||||||
* Avoids flexbox, uses standard block/inline styling for flawless PDF generation.
|
* Avoids flexbox, uses standard block/inline styling for flawless PDF generation.
|
||||||
*/
|
*/
|
||||||
body {
|
body {
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings: "liga" 0, "clig" 0;
|
||||||
|
letter-spacing: 0px;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #222;
|
color: #222;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px 40px;
|
padding: 15px 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 2px solid #1a237e;
|
border-bottom: 2px solid #1a237e;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -48,40 +52,48 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #1a237e;
|
color: #1a237e;
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
margin-top: 20px;
|
margin-top: 15px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 6px;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p { margin: 0 0 10px 0; text-align: justify; }
|
p { margin: 0 0 6px 0; text-align: justify; }
|
||||||
|
|
||||||
.job-block { margin-bottom: 15px; }
|
.job-block { margin-bottom: 10px; }
|
||||||
|
|
||||||
.job-header {
|
.job-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
display: table; /* هذا يحل مشاكل Dompdf */
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-title {
|
.job-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 14px;
|
font-size: 13.5px; /* تصغير طفيف جداً */
|
||||||
color: #222;
|
color: #222;
|
||||||
float: left;
|
float: left;
|
||||||
|
width: 70%; /* إجبار العنوان على عدم تجاوز 70% من السطر */
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-meta {
|
.job-meta {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #1a237e;
|
color: #1a237e;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
float: right;
|
float: right;
|
||||||
|
width: 28%; /* تخصيص مساحة آمنة للتاريخ */
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear { clear: both; }
|
.clear { clear: both; }
|
||||||
|
|
||||||
ul { margin: 5px 0 10px 0; padding-left: 20px; }
|
|
||||||
li { margin-bottom: 6px; text-align: justify; }
|
|
||||||
|
|
||||||
|
ul { margin: 4px 0 8px 0; padding-left: 20px; }
|
||||||
|
li { margin-bottom: 4px; text-align: justify; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -91,7 +103,10 @@
|
|||||||
<!-- PHP WILL INJECT THE AI-GENERATED HEADLINE HERE -->
|
<!-- PHP WILL INJECT THE AI-GENERATED HEADLINE HERE -->
|
||||||
<div class="headline">{{JOB_HEADLINE}}</div>
|
<div class="headline">{{JOB_HEADLINE}}</div>
|
||||||
<div class="contact">
|
<div class="contact">
|
||||||
Amman, Jordan | +962 79 858 3052 | hamzaayed@intaleqapp.com | linkedin.com/in/hamza-ayed
|
Amman, Jordan | +962 79 858 3052 | <a href="mailto:hamzaayed.dev@gmail.com" style="color: #555; text-decoration: none;">hamzaayed.dev@gmail.com</a><br>
|
||||||
|
<a href="https://linkedin.com/in/hamza-ayed" style="color: #555; text-decoration: none; font-weight: bold;">linkedin.com/in/hamza-ayed</a> |
|
||||||
|
<a href="https://github.com/Hamza-Ayed" style="color: #555; text-decoration: none; font-weight: bold;">github.com/Hamza-Ayed</a> |
|
||||||
|
<a href="https://intaleqapp.com/hamza.html" style="color: #1a237e; text-decoration: underline; font-weight: bold;">intaleqapp.com/hamza.html (Portfolio)</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,71 +114,95 @@
|
|||||||
<!-- PHP WILL INJECT THE AI-GENERATED SUMMARY HERE -->
|
<!-- PHP WILL INJECT THE AI-GENERATED SUMMARY HERE -->
|
||||||
<p>{{TAILORED_SUMMARY}}</p>
|
<p>{{TAILORED_SUMMARY}}</p>
|
||||||
|
|
||||||
<div class="section-title">Core Competencies & Skills</div>
|
|
||||||
|
|
||||||
|
<div class="section-title">Technical Skills</div>
|
||||||
<!-- PHP WILL INJECT ATS KEYWORDS HERE -->
|
<!-- PHP WILL INJECT ATS KEYWORDS HERE -->
|
||||||
<p><strong>Targeted Expertise:</strong> {{DYNAMIC_SKILLS}}</p>
|
<p><strong>Core Focus:</strong> {{DYNAMIC_SKILLS}}</p>
|
||||||
<p><strong>Technologies:</strong> Flutter/Dart, PHP, Python (FastAPI/Flask), Node.js, MySQL, PostgreSQL, AWS/Cloud Infrastructure, Git, Docker.</p>
|
<p><strong>Backend & Architecture:</strong> PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), REST APIs, WebSockets, Event-Driven Architecture</p>
|
||||||
<p><strong>Domains:</strong> System Architecture, Distributed Systems, GIS/Mapping (OSM), FinTech (Payment Gateways), AI/ML Integration, Zero-Trust Security.</p>
|
<p><strong>Infrastructure & Cloud:</strong> Docker, Linux, Nginx, Redis, CI/CD (GitHub Actions), AWS</p>
|
||||||
|
<p><strong>Databases & Geospatial:</strong> PostgreSQL/PostGIS, MySQL, OpenStreetMap (OSM), GraphHopper, MapLibre GL, Spatial Queries</p>
|
||||||
|
<p><strong>Mobile:</strong> Flutter/Dart, GetX, BLoC/Cubit</p>
|
||||||
|
|
||||||
|
<div class="section-title">Selected Technical Highlights</div>
|
||||||
|
<ul>
|
||||||
|
<li>Built proprietary OSM-based mapping infrastructure replacing Google Maps APIs</li>
|
||||||
|
<li>Engineered WebSocket-based real-time ride tracking systems</li>
|
||||||
|
<li>Developed offline-first mapping SDKs published on pub.dev and npm</li>
|
||||||
|
<li>Designed async AI document-processing pipelines using vision models</li>
|
||||||
|
<li>Optimized routing infrastructure using GraphHopper and PostGIS</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="section-title">Professional Experience</div>
|
<div class="section-title">Professional Experience</div>
|
||||||
|
|
||||||
<div class="job-block">
|
<div class="job-block">
|
||||||
<div class="job-header">
|
<div class="job-header">
|
||||||
<div class="job-title">CTO & Solutions Architect — Intaleq</div>
|
<div class="job-title">CTO & Technical Architect | Systems & Backend Focus — Intaleq | </div>
|
||||||
<div class="job-meta">Jan 2025 – Present | Syria / Remote</div>
|
<div class="job-meta">January 2024 – Present | Remote (MENA Region)</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Led the full-stack architecture of a smart transportation ecosystem supporting 1,800+ drivers and 2,500+ riders.</li>
|
<li>Led the full-stack architecture of a smart transportation ecosystem supporting 1,800+ drivers and 2,500+ riders, building the core backend with PHP/Workerman.</li>
|
||||||
<li>Built a proprietary mapping platform (IntaleqMaps) on OpenStreetMap, eliminating reliance on Google Maps API and saving $10,000+/month in operational costs.</li>
|
<li>Architected the proprietary mapping platform (IntaleqMaps), orchestrating GraphHopper routing, OSM tile serving, and PostGIS spatial queries via a NestJS API layer, saving $10,000+/month in operational costs.</li>
|
||||||
<li>Designed secure, custom payment infrastructure for environments lacking standard payment APIs, ensuring high-availability transaction integrity.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="job-block">
|
<div class="job-block">
|
||||||
<div class="job-header">
|
<div class="job-header">
|
||||||
<div class="job-title">Co-Founder & Lead Developer — Tripz Egypt</div>
|
<div class="job-title">Founding Engineer & Lead Backend Architect — Tripz Egypt | </div>
|
||||||
<div class="job-meta">Jan 2024 – Present | Cairo / Remote</div>
|
<div class="job-meta">Jan 2023 – Present | Cairo / Remote</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Architected Egypt's homegrown ride-hailing platform, managing the entire distributed ecosystem with real-time tracking and dispatching.</li>
|
<li>Co-founded and architected Egypt's homegrown ride-hailing platform, managing the entire distributed ecosystem with real-time tracking for 4,318 drivers and 2,464 riders.</li>
|
||||||
<li>Implemented robust microservices for real-time driver/rider matching and route optimization using event-driven architecture.</li>
|
<li>Implemented robust event-driven architecture for real-time driver/rider matching and WebSockets-based route optimization.</li>
|
||||||
|
<li>Integrated local digital payment gateways and driver payout systems, supporting an 8% commission structure that drove a $0.78 Customer Acquisition Cost.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="job-block">
|
<div class="job-block">
|
||||||
<div class="job-header">
|
<div class="job-header">
|
||||||
<div class="job-title">Mobile Solutions Architect — Freelance</div>
|
<div class="job-title">Senior Systems Architect — Mobile & Backend (Freelance) | </div>
|
||||||
<div class="job-meta">Jan 2017 – Dec 2023 | Jordan / Remote</div>
|
<div class="job-meta">Jan 2017 – Dec 2023 | Jordan / Remote</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Delivered 25+ production enterprise applications across GIS, FinTech, HR, and utilities for clients across the MENA region.</li>
|
<li>Delivered 25+ production enterprise applications across GIS, FinTech, and utilities, leading full lifecycle architecture for clients in the MENA region.</li>
|
||||||
<li>Integrated AI vision models for document analysis (KYC) and automated invoice processing pipelines.</li>
|
<li>Developed Nabih (AI Smart Responder): designed and built an intelligent virtual assistant system connecting a Flutter mobile application (GetX/Cubit) with custom PHP backend APIs.</li>
|
||||||
|
<li>Developed Musadaq (AI-Powered Invoice Processing): engineered an automated KYC and invoice document processor using AI vision models and asynchronous processing pipelines.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="job-block">
|
<div class="section-title">Notable Projects</div>
|
||||||
<div class="job-header">
|
<ul>
|
||||||
<div class="job-title">Operations & Logistics Officer — Jordan Armed Forces</div>
|
<li><strong>IntaleqMaps Engine:</strong> Engineered a proprietary mapping platform orchestrating GraphHopper routing, OSM tile serving, and PostGIS spatial queries via a NestJS API layer, replacing Google Maps API across the MENA region.</li>
|
||||||
<div class="job-meta">Oct 2003 – Nov 2023 | Jordan</div>
|
<li><strong>Nabih AI Virtual Assistant:</strong> Architected an automated customer support solution connecting a Flutter application (GetX/Cubit) to custom high-concurrency PHP backend APIs.</li>
|
||||||
<div class="clear"></div>
|
<li><strong>Musadaq AI Document Processor:</strong> Engineered an automated KYC and invoice document processing platform utilizing AI vision models and async processing pipelines.</li>
|
||||||
</div>
|
<li><strong>Meta Ads Manager & SaaS Gateways:</strong> Deployed multi-tenant platforms using NestJS/React (Meta Ads Manager) and high-concurrency Node.js (WhatsApp bridge managing headless Puppeteer sessions in Docker) to automate operations.</li>
|
||||||
<ul>
|
</ul>
|
||||||
<li>Retired Lieutenant Colonel. Directed logistics and crisis management operations, leading teams of 50+ personnel.</li>
|
|
||||||
<li>Applied rigorous, security-first methodologies to organizational leadership, disaster recovery, and operational planning.</li>
|
<div class="section-title">Open Source Contributions</div>
|
||||||
</ul>
|
<ul>
|
||||||
</div>
|
<li><strong>intaleq_maps (Flutter SDK):</strong> Published on <a href="https://pub.dev/packages/intaleq_maps" style="color: #1a237e; text-decoration: none; font-weight: bold;">pub.dev/packages/intaleq_maps</a>. Custom map rendering, offline caching, and route plotting package optimized for low-bandwidth environments.</li>
|
||||||
|
<li><strong>intaleq-maps-gl (NPM Library):</strong> Published on <a href="https://libraries.io/npm/intaleq-maps-gl" style="color: #1a237e; text-decoration: none; font-weight: bold;">npmjs.com/package/intaleq-maps-gl</a>. Web-based Mapbox GL compatible library optimized for custom OSM tiles and routing integration.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="section-title">Education & Certifications</div>
|
<div class="section-title">Education & Certifications</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>BS Mathematics</strong>, Mutah University (2003–2007)</li>
|
<li><strong>BS Mathematics (Applied Computing & Algorithms)</strong>, Mutah University (2003–2007)</li>
|
||||||
<li>Google Data Analytics Professional Certificate</li>
|
<li><strong>Google Data Analytics</strong> Professional Certificate (Coursera)</li>
|
||||||
<li>IBM Data Science Professional Certificate</li>
|
<li><strong>IBM Data Analyst</strong> Professional Certificate (Coursera)</li>
|
||||||
<li>Meta Mobile Development Certificate</li>
|
<li><strong>Meta APIs & Django Web Framework</strong> Course Certificates (Coursera)</li>
|
||||||
<li><em>Total of 51 professional certifications across software engineering, AI, and enterprise architecture.</em></li>
|
<li><strong>AWS Cloud Practitioner & Cloud Architecture Fundamentals</strong></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-title">Languages</div>
|
||||||
|
<p><strong>Arabic:</strong> Native | <strong>English:</strong> Professional Working Proficiency | <strong>Turkish:</strong> Limited Working Proficiency</p>
|
||||||
|
|
||||||
|
<div class="section-title">Availability & Work Authorization</div>
|
||||||
|
<p style="text-align: center; font-weight: bold; color: #1a237e; margin-top: 10px;">
|
||||||
|
Available Immediately | Open to Remote, Hybrid & Relocation (GCC/Europe/MENA) | Valid Passport
|
||||||
|
</p>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
200
server/cv_template_amman.html
Normal file
200
server/cv_template_amman.html
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings: "liga" 0, "clig" 0;
|
||||||
|
letter-spacing: 0px;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #222;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 15px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid #1a237e;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1a237e;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #424242;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a237e;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin-top: 15px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { margin: 0 0 6px 0; text-align: justify; }
|
||||||
|
|
||||||
|
.job-block { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.job-header {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #222;
|
||||||
|
float: left;
|
||||||
|
width: 70%;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #1a237e;
|
||||||
|
font-weight: bold;
|
||||||
|
float: right;
|
||||||
|
width: 28%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear { clear: both; }
|
||||||
|
|
||||||
|
ul { margin: 4px 0 8px 0; padding-left: 20px; }
|
||||||
|
li { margin-bottom: 4px; text-align: justify; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1>Hamza Ayed</h1>
|
||||||
|
<div class="headline">{{JOB_HEADLINE}}</div>
|
||||||
|
<div class="contact">
|
||||||
|
Amman, Jordan | +962 79 858 3052 | <a href="mailto:hamzaayed.dev@gmail.com" style="color: #555; text-decoration: none;">hamzaayed.dev@gmail.com</a><br>
|
||||||
|
<a href="https://linkedin.com/in/hamza-ayed" style="color: #555; text-decoration: none; font-weight: bold;">linkedin.com/in/hamza-ayed</a> |
|
||||||
|
<a href="https://github.com/Hamza-Ayed" style="color: #555; text-decoration: none; font-weight: bold;">github.com/Hamza-Ayed</a> |
|
||||||
|
<a href="https://intaleqapp.com/hamza.html" style="color: #1a237e; text-decoration: underline; font-weight: bold;">intaleqapp.com/hamza.html (Portfolio)</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Professional Summary</div>
|
||||||
|
<p>{{TAILORED_SUMMARY}}</p>
|
||||||
|
|
||||||
|
<div class="section-title">Technical Skills</div>
|
||||||
|
<p><strong>Core Focus:</strong> {{DYNAMIC_SKILLS}}</p>
|
||||||
|
<p><strong>Backend & Architecture:</strong> PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), REST APIs, WebSockets, Microservices</p>
|
||||||
|
<p><strong>Infrastructure & Cloud:</strong> Docker, Linux, Nginx, Redis, CI/CD (GitHub Actions), AWS</p>
|
||||||
|
<p><strong>Databases:</strong> PostgreSQL, MySQL, Redis, Database Design & Query Optimization</p>
|
||||||
|
<p><strong>Geospatial (Supplemental):</strong> PostGIS, OpenStreetMap (OSM), GraphHopper, MapLibre GL, Spatial Queries</p>
|
||||||
|
<p><strong>Mobile:</strong> Flutter/Dart, GetX, BLoC/Cubit</p>
|
||||||
|
|
||||||
|
<div class="section-title">Impact & Delivery</div>
|
||||||
|
<ul>
|
||||||
|
<li>Built and scaled platforms serving 6,000+ active users across two markets with zero downtime deployments</li>
|
||||||
|
<li>Engineered proprietary OSM-based mapping infrastructure replacing Google Maps APIs across the MENA region — saving $10,000+/month in operational costs</li>
|
||||||
|
<li>Designed high-concurrency WebSocket systems handling real-time operations at scale</li>
|
||||||
|
<li>Delivered 25+ production applications across healthcare, logistics, fintech, and utilities for MENA clients</li>
|
||||||
|
<li>Published open-source mapping SDKs on pub.dev and npm, used in production environments</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-title">Professional Experience</div>
|
||||||
|
|
||||||
|
<div class="job-block">
|
||||||
|
<div class="job-header">
|
||||||
|
<div class="job-title">Technical Lead & Backend Engineer — Intaleq | </div>
|
||||||
|
<div class="job-meta">January 2024 – Present | Amman, Jordan</div>
|
||||||
|
<div class="clear"></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>Built full-stack transportation platform from zero to production: PHP/Workerman backend, Flutter mobile apps, WebSocket-based real-time dispatcher — serving 1,800+ active drivers.</li>
|
||||||
|
<li>Developed proprietary mapping platform (IntaleqMaps) orchestrating GraphHopper routing, OSM tile serving, and PostGIS spatial queries via a NestJS API layer — reducing mapping API costs by $10,000+/month.</li>
|
||||||
|
<li>Designed event-driven architecture for real-time driver/rider matching, live tracking, and automated fare calculation.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-block">
|
||||||
|
<div class="job-header">
|
||||||
|
<div class="job-title">Lead Backend Engineer — Tripz Egypt | </div>
|
||||||
|
<div class="job-meta">Jan 2023 – Present | Cairo / Remote</div>
|
||||||
|
<div class="clear"></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>Built complete ride-hailing platform: PHP backend system, Flutter rider and driver apps, real-time WebSocket communication layer, and local payment gateway integrations.</li>
|
||||||
|
<li>Engineered event-driven driver/rider matching system serving 4,318 drivers with a $0.78 customer acquisition cost — the lowest in the Egyptian market.</li>
|
||||||
|
<li>Integrated local digital payment gateways and designed automated driver payout infrastructure supporting an 8% commission model.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-block">
|
||||||
|
<div class="job-header">
|
||||||
|
<div class="job-title">Senior Backend Engineer — Freelance | </div>
|
||||||
|
<div class="job-meta">Jan 2017 – Dec 2023 | Jordan / Remote</div>
|
||||||
|
<div class="clear"></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>Delivered 25+ applications for MENA clients across healthcare, logistics, HR technology, sports platforms, and utility management systems — full lifecycle from architecture to deployment.</li>
|
||||||
|
<li>Built automated customer support platform: designed Flutter application (GetX/Cubit) connected to high-concurrency PHP backend APIs handling real-time query resolution.</li>
|
||||||
|
<li>Developed document processing system: engineered async processing pipelines using vision models for automated KYC and invoice data extraction.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Notable Projects</div>
|
||||||
|
<ul>
|
||||||
|
<li><strong>IntaleqMaps Engine:</strong> Proprietary mapping platform orchestrating GraphHopper routing, OSM tile serving, and PostGIS spatial queries via a NestJS API layer — replacing Google Maps API across the MENA region.</li>
|
||||||
|
<li><strong>Tripz Egypt Platform:</strong> End-to-end ride-hailing system with 8 ride types, real-time matching, custom payment infrastructure, and AI-powered KYC verification.</li>
|
||||||
|
<li><strong>Customer Support Automation Platform:</strong> Built an automated query resolution system connecting a Flutter application (GetX/Cubit) to high-concurrency PHP backend APIs.</li>
|
||||||
|
<li><strong>Document Processing Platform:</strong> Engineered automated KYC and invoice processing using vision models and asynchronous processing pipelines.</li>
|
||||||
|
<li><strong>Meta Ads Manager (SaaS):</strong> Deployed multi-tenant platform using NestJS/React for automated ad campaign management.</li>
|
||||||
|
<li><strong>WhatsApp Bridge:</strong> High-concurrency Node.js service managing headless Puppeteer sessions in Docker for automated messaging operations.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-title">Open Source Contributions</div>
|
||||||
|
<ul>
|
||||||
|
<li><strong>intaleq_maps (Flutter SDK):</strong> Published on <a href="https://pub.dev/packages/intaleq_maps" style="color: #1a237e; text-decoration: none; font-weight: bold;">pub.dev/packages/intaleq_maps</a>. Custom map rendering, offline caching, and route plotting package optimized for low-bandwidth environments.</li>
|
||||||
|
<li><strong>intaleq-maps-gl (NPM Library):</strong> Published on <a href="https://libraries.io/npm/intaleq-maps-gl" style="color: #1a237e; text-decoration: none; font-weight: bold;">npmjs.com/package/intaleq-maps-gl</a>. Web-based Mapbox GL compatible library optimized for custom OSM tiles and routing integration.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-title">Education & Certifications</div>
|
||||||
|
<ul>
|
||||||
|
<li><strong>BS Mathematics (Applied Computing & Algorithms)</strong>, Mutah University (2003–2007)</li>
|
||||||
|
<li><strong>Google Data Analytics</strong> Professional Certificate (Coursera)</li>
|
||||||
|
<li><strong>IBM Data Analyst</strong> Professional Certificate (Coursera)</li>
|
||||||
|
<li><strong>Meta APIs & Django Web Framework</strong> Course Certificates (Coursera)</li>
|
||||||
|
<li><strong>AWS Cloud Practitioner & Cloud Architecture Fundamentals</strong></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-title">Languages</div>
|
||||||
|
<p><strong>Arabic:</strong> Native | <strong>English:</strong> Professional Working Proficiency | <strong>Turkish:</strong> Limited Working Proficiency</p>
|
||||||
|
|
||||||
|
<div class="section-title">Availability</div>
|
||||||
|
<p style="text-align: center; font-weight: bold; color: #1a237e; margin-top: 10px;">
|
||||||
|
Based in Amman, Jordan — Available Immediately | Open to On-site, Hybrid & Remote Roles in Jordan & MENA
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -48,7 +48,8 @@ if (empty($apiKey)) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$model = "gemini-2.5-flash";
|
// Standardized on gemini-flash-lite-latest due to quota limits
|
||||||
|
$model = "gemini-flash-lite-latest";
|
||||||
$geminiUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . $apiKey;
|
$geminiUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . $apiKey;
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -56,16 +57,44 @@ $geminiUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:g
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
if ($action === 'generatePdf') {
|
if ($action === 'generatePdf') {
|
||||||
$jobDescription = $data['jobDescription'] ?? '';
|
$jobDescription = $data['jobDescription'] ?? '';
|
||||||
|
$template = $data['template'] ?? 'default'; // 'amman' or 'default'
|
||||||
$prompt = "You are an expert ATS CV tailor. Read the following job description and generate tailored content for my CV to maximize my chances of getting an interview.
|
|
||||||
|
// --- Amman Market Prompt (Senior Backend Engineer & Technical Lead) ---
|
||||||
|
if ($template === 'amman') {
|
||||||
|
$prompt = "You are an expert ATS CV tailor for the Jordan/Amman local tech market. Read the following job description and generate tailored content.
|
||||||
|
|
||||||
|
STRICT INTEGRITY RULES:
|
||||||
|
1. NEVER invent skills I do not have. My TRUE technical stack is: PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), PostgreSQL/PostGIS, Docker, Flutter, GetX, BLoC, WebSockets, OpenStreetMap.
|
||||||
|
2. Do NOT add data science skills like TensorFlow, PyTorch, Scikit-learn, Hadoop, or MLOps.
|
||||||
|
3. My title MUST be aligned with 'Senior Backend Engineer', 'Technical Lead', or 'Lead Backend Engineer'. NEVER use 'Solutions Architect', 'AI Developer', or 'CTO'.
|
||||||
|
|
||||||
Return ONLY a valid JSON object with EXACTLY three keys: 'headline', 'summary', and 'skills'.
|
Return ONLY a valid JSON object with EXACTLY three keys: 'headline', 'summary', and 'skills'.
|
||||||
The 'headline' should be a 5-6 word professional title relevant to the job.
|
The 'headline' should be a clean, confident title based on my TRUE skills — matching Amman market expectations (Senior/Lead Backend Engineer).
|
||||||
The 'summary' should be a 3-sentence powerful paragraph highlighting skills relevant to the job.
|
The 'summary' should open with a hook about building and scaling production systems in the MENA region, emphasizing delivery, cost optimization, and hands-on engineering. Connect my real achievements to the job requirements. Keep it professional and direct — NO academic fluff, NO 'I leverage my expertise' phrases.
|
||||||
The 'skills' should be a comma-separated list of 10 highly relevant ATS keywords.
|
The 'skills' should be a comma-separated list of 10 highly relevant ATS keywords from MY ACTUAL SKILLS that match the job. Prioritize backend, API, database, and infrastructure skills over GIS.
|
||||||
Do NOT use markdown blocks like ```json, just return raw JSON text.
|
Do NOT use markdown blocks like ```json, just return raw JSON text.
|
||||||
|
|
||||||
Job Description:
|
Job Description:
|
||||||
" . substr($jobDescription, 0, 4000);
|
" . substr($jobDescription, 0, 4000);
|
||||||
|
}
|
||||||
|
// --- Default Prompt (Current — Enterprise/GCC) ---
|
||||||
|
else {
|
||||||
|
$prompt = "You are an expert ATS CV tailor. Read the following job description and generate tailored content for my CV to maximize my chances of getting an interview.
|
||||||
|
|
||||||
|
STRICT INTEGRITY RULES:
|
||||||
|
1. NEVER invent skills I do not have. My TRUE technical stack is: PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), PostgreSQL/PostGIS, Docker, Flutter, GetX, BLoC, WebSockets, OpenStreetMap.
|
||||||
|
2. Do NOT add data science skills like TensorFlow, PyTorch, Scikit-learn, Hadoop, or MLOps.
|
||||||
|
3. My title MUST be aligned with 'Solutions Architect', 'Senior Backend Engineer', or 'Senior Mobile Engineer'. NEVER title me 'Senior AI Developer'.
|
||||||
|
|
||||||
|
Return ONLY a valid JSON object with EXACTLY three keys: 'headline', 'summary', and 'skills'.
|
||||||
|
The 'headline' should be a clean, confident title based on my TRUE skills.
|
||||||
|
The 'summary' MUST open with exactly this hook: 'Built two production ride-hailing platforms from zero to thousands of users, on proprietary infrastructure, in high-complexity and emerging markets.' Then use the next 2 sentences to seamlessly tie my relevant TRUE skills to the job description requirements.
|
||||||
|
The 'skills' should be a comma-separated list of 10 highly relevant ATS keywords from MY ACTUAL SKILLS that match the job.
|
||||||
|
Do NOT use markdown blocks like ```json, just return raw JSON text.
|
||||||
|
|
||||||
|
Job Description:
|
||||||
|
" . substr($jobDescription, 0, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
$payload = json_encode([
|
$payload = json_encode([
|
||||||
"contents" => [["parts" => [["text" => $prompt]]]],
|
"contents" => [["parts" => [["text" => $prompt]]]],
|
||||||
@@ -92,12 +121,17 @@ Job Description:
|
|||||||
$aiText = str_replace(['```json', '```'], '', $aiText);
|
$aiText = str_replace(['```json', '```'], '', $aiText);
|
||||||
$parsedJson = json_decode(trim($aiText), true);
|
$parsedJson = json_decode(trim($aiText), true);
|
||||||
|
|
||||||
$headline = $parsedJson['headline'] ?? "Solutions Architect & Technical Leader";
|
$defaultHeadline = ($template === 'amman')
|
||||||
|
? "Senior Backend Engineer & Technical Lead"
|
||||||
|
: "Solutions Architect & Technical Leader";
|
||||||
|
$headline = $parsedJson['headline'] ?? $defaultHeadline;
|
||||||
$summary = $parsedJson['summary'] ?? "Experienced professional with a strong background in software engineering.";
|
$summary = $parsedJson['summary'] ?? "Experienced professional with a strong background in software engineering.";
|
||||||
$skills = $parsedJson['skills'] ?? "Architecture, APIs, Cloud, Backend Systems, System Design";
|
$skills = $parsedJson['skills'] ?? "Architecture, APIs, Cloud, Backend Systems, System Design";
|
||||||
|
|
||||||
$templatePath = __DIR__ . '/cv_template.html';
|
$templateFile = ($template === 'amman')
|
||||||
$html = file_get_contents($templatePath);
|
? __DIR__ . '/cv_template_amman.html'
|
||||||
|
: __DIR__ . '/cv_template.html';
|
||||||
|
$html = file_get_contents($templateFile);
|
||||||
$html = str_replace('{{JOB_HEADLINE}}', htmlspecialchars($headline), $html);
|
$html = str_replace('{{JOB_HEADLINE}}', htmlspecialchars($headline), $html);
|
||||||
$html = str_replace('{{TAILORED_SUMMARY}}', htmlspecialchars($summary), $html);
|
$html = str_replace('{{TAILORED_SUMMARY}}', htmlspecialchars($summary), $html);
|
||||||
$html = str_replace('{{DYNAMIC_SKILLS}}', htmlspecialchars($skills), $html);
|
$html = str_replace('{{DYNAMIC_SKILLS}}', htmlspecialchars($skills), $html);
|
||||||
@@ -159,3 +193,113 @@ if ($action === 'generateText') {
|
|||||||
echo $response;
|
echo $response;
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ACTION 3: Smart Comment Generator
|
||||||
|
// ==========================================
|
||||||
|
if ($action === 'generateComment') {
|
||||||
|
$postText = substr($data['postText'] ?? '', 0, 3000);
|
||||||
|
|
||||||
|
if (empty($postText)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["error" => "postText is required."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptFile = __DIR__ . '/prompts/comment_prompt.txt';
|
||||||
|
if (!file_exists($promptFile)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["error" => "Comment prompt file not found on server."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptTemplate = file_get_contents($promptFile);
|
||||||
|
$prompt = str_replace('{{POST_TEXT}}', $postText, $promptTemplate);
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
"contents" => [["parts" => [["text" => $prompt]]]],
|
||||||
|
"generationConfig" => ["temperature" => 0.75, "maxOutputTokens" => 256]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init($geminiUrl);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["error" => "Gemini API Error", "details" => json_decode($response)]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseData = json_decode($response, true);
|
||||||
|
$commentText = trim($responseData['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||||
|
|
||||||
|
if (empty($commentText)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["error" => "Empty comment from AI."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(["success" => true, "comment" => $commentText]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ACTION 4: Repurpose Post Generator
|
||||||
|
// ==========================================
|
||||||
|
if ($action === 'repurposePost') {
|
||||||
|
$postText = substr($data['postText'] ?? '', 0, 3000);
|
||||||
|
|
||||||
|
if (empty($postText)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["error" => "postText is required."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptFile = __DIR__ . '/prompts/repurpose_prompt.txt';
|
||||||
|
if (!file_exists($promptFile)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["error" => "Repurpose prompt file not found on server."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptTemplate = file_get_contents($promptFile);
|
||||||
|
$prompt = str_replace('{{POST_TEXT}}', $postText, $promptTemplate);
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
"contents" => [["parts" => [["text" => $prompt]]]],
|
||||||
|
"generationConfig" => ["temperature" => 0.75, "maxOutputTokens" => 800]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init($geminiUrl);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["error" => "Gemini API Error", "details" => json_decode($response)]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseData = json_decode($response, true);
|
||||||
|
$resultText = trim($responseData['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||||
|
|
||||||
|
if (empty($resultText)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["error" => "Empty result from AI."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(["success" => true, "result" => $resultText]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|||||||
21
server/prompts/comment_prompt.txt
Normal file
21
server/prompts/comment_prompt.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
You are a Solutions Architect and Engineering Leader. Read the following LinkedIn post and generate a JSON response.
|
||||||
|
|
||||||
|
STRICT RULES FOR THE COMMENT:
|
||||||
|
- Max 3 sentences. It MUST flow naturally as a single, coherent, human-written comment. Do NOT output disconnected, bullet-point-like statements.
|
||||||
|
- Tone: Calm, authoritative, direct, and operator-level. Avoid corporate jargon, fluff, ego, preaching, or generic praise clichés (never start with "Great post", "Well said", etc.).
|
||||||
|
- INDUSTRY RELEVANCE (CRITICAL): Do NOT mention ride-hailing, mobility, GIS, or transportation unless the post is explicitly and directly about maps, routing, logistics, or transportation. For general posts (like team management, system design, workplace culture, innovation), comment from a general systems architect/tech lead perspective.
|
||||||
|
- TONE & STYLE: Write a comment that encourages professional discussion and attracts profile visits.
|
||||||
|
- LANGUAGE: Match the language of the post. If the post is in Arabic, write the comment in Arabic. If the post is in English, write the comment in English.
|
||||||
|
- Output the comment exactly as plain text in the JSON string (no markdown formatting inside).
|
||||||
|
|
||||||
|
STRICT RULES FOR THE ARABIC SUMMARY & CREDIBILITY CHECK:
|
||||||
|
- Analyze the post objectively. No flattery. No automatic agreement.
|
||||||
|
- Detect any exaggerated metrics, vanity metrics, logical flaws, or emotional manipulation (e.g., VC/startup hustle culture tropes).
|
||||||
|
- Provide a 2-3 sentence Arabic response. The first sentence should summarize the post. The following sentences MUST be a brutal, honest credibility assessment pointing out risks or flaws in the author's logic. (Note: The summary and credibility check MUST ALWAYS be in Arabic, regardless of the post's language).
|
||||||
|
|
||||||
|
Return ONLY a valid JSON object with EXACTLY two keys:
|
||||||
|
1. "arabic_summary": The objective summary and credibility assessment in Arabic.
|
||||||
|
2. "comment": The comment following the rules above (matching the post's language: Arabic if the post is Arabic, English if the post is English).
|
||||||
|
|
||||||
|
POST TEXT:
|
||||||
|
{{POST_TEXT}}
|
||||||
21
server/prompts/repurpose_prompt.txt
Normal file
21
server/prompts/repurpose_prompt.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
You are Hamza Ayed, a Senior Solutions Architect and technical leader.
|
||||||
|
Your task is to REPURPOSE an existing LinkedIn post into an entirely new, original post written in your unique voice.
|
||||||
|
|
||||||
|
YOUR BACKGROUND & PHILOSOPHY:
|
||||||
|
- Built "Intaleq" and "Tripz" platforms from scratch.
|
||||||
|
- Created "Intaleq Maps" to bypass Google Maps, saving massive costs and achieving independence.
|
||||||
|
- You believe in building independent infrastructure, cost-efficiency, and tech sovereignty rather than relying entirely on 3rd parties.
|
||||||
|
|
||||||
|
STRICT RULES:
|
||||||
|
1. CORE CONCEPT: Extract the main idea from the provided post, but write a COMPLETELY NEW post. Do not just summarize. Reframe it from your Architect/Leadership perspective.
|
||||||
|
2. LANGUAGE: Match the language of the original post. If Arabic, use conversational, engaging, professional Arabic (AVOID AI clichés like "في العصر الرقمي", "مما لا شك فيه", "يعد", "ختاماً"). Write naturally like a seasoned expert.
|
||||||
|
3. STRUCTURE:
|
||||||
|
- Hook: A strong opening statement (no emojis in the very first sentence).
|
||||||
|
- Body: Your perspective on the topic. Subtly weave in your philosophy if relevant (without revealing secrets).
|
||||||
|
- Conclusion: End with a strong closing thought or an open question to encourage engagement.
|
||||||
|
4. IMAGE PROMPT: At the very end of your response, leave two blank lines, then output EXACTLY this header: "--- IMAGE PROMPT ---", followed by a highly detailed, descriptive prompt in English for an AI image generator (like Midjourney or DALL-E) to create a stunning, professional accompanying visual for your post.
|
||||||
|
5. NO EMOJIS in the image prompt section. You may use a few appropriate emojis in the main post text.
|
||||||
|
6. OUTPUT FORMAT: Return the new post text, then the image prompt section. Nothing else.
|
||||||
|
|
||||||
|
POST TO REPURPOSE:
|
||||||
|
{{POST_TEXT}}
|
||||||
10
speech.html
Normal file
10
speech.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Speech Recognition Frame</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="speech.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
120
speech.js
Normal file
120
speech.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// speech.js - Handles webkitSpeechRecognition inside the injected iframe
|
||||||
|
|
||||||
|
let recognition = null;
|
||||||
|
let isRecording = false;
|
||||||
|
let localStream = null;
|
||||||
|
|
||||||
|
function stopMediaTracks() {
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach(track => track.stop());
|
||||||
|
localStream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRecognition(language) {
|
||||||
|
if (recognition) return recognition;
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) {
|
||||||
|
throw new Error('Speech recognition not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
recognition = new SpeechRecognition();
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.lang = language || 'ar-SA';
|
||||||
|
recognition.maxAlternatives = 1;
|
||||||
|
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
let interimText = '';
|
||||||
|
let finalText = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i];
|
||||||
|
if (result.isFinal) {
|
||||||
|
finalText += result[0].transcript + ' ';
|
||||||
|
} else {
|
||||||
|
interimText += result[0].transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'SPEECH_RESULT',
|
||||||
|
payload: { interimText, finalText }
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = (event) => {
|
||||||
|
console.error('[Speech Iframe] Recognition error:', event.error);
|
||||||
|
|
||||||
|
if (event.error !== 'no-speech') {
|
||||||
|
isRecording = false;
|
||||||
|
stopMediaTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'SPEECH_ERROR',
|
||||||
|
payload: { error: event.error }
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (isRecording) {
|
||||||
|
try {
|
||||||
|
recognition.start();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Speech Iframe] Restart failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopMediaTracks();
|
||||||
|
window.parent.postMessage({ type: 'SPEECH_END' }, '*');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return recognition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages broadcasted across the extension
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const message = event.data;
|
||||||
|
if (!message || !message.type) return;
|
||||||
|
|
||||||
|
if (message.type === 'START_RECORDING_FROM_POPUP') {
|
||||||
|
const lang = message.payload?.language || 'ar-SA';
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
.then((stream) => {
|
||||||
|
localStream = stream;
|
||||||
|
try {
|
||||||
|
if (!recognition || recognition.lang !== lang) {
|
||||||
|
recognition = initRecognition(lang);
|
||||||
|
}
|
||||||
|
if (!isRecording) {
|
||||||
|
recognition.start();
|
||||||
|
isRecording = true;
|
||||||
|
// Tell popup it started successfully
|
||||||
|
window.parent.postMessage({ type: 'SPEECH_START_SUCCESS' }, '*');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Speech Iframe] Failed to start:', e);
|
||||||
|
window.parent.postMessage({ type: 'SPEECH_ERROR', payload: { error: e.message } }, '*');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[Speech Iframe] getUserMedia failed:', err);
|
||||||
|
window.parent.postMessage({ type: 'SPEECH_ERROR', payload: { error: 'not-allowed' } }, '*');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'STOP_RECORDING_FROM_POPUP') {
|
||||||
|
if (isRecording && recognition) {
|
||||||
|
isRecording = false;
|
||||||
|
try {
|
||||||
|
recognition.stop();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Speech Iframe] Stop failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopMediaTracks();
|
||||||
|
}
|
||||||
|
});
|
||||||
2
test_gemini.js
Normal file
2
test_gemini.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const settings = JSON.parse(fs.readFileSync('/Users/hamzaaleghwairyeen/.gemini/antigravity/brain/1fc80e71-ad9e-4306-9d23-0fcef50f8b3e/scratch/settings.json', 'utf8').catch ? '{}' : '{}'); // wait, I don't have his API key here...
|
||||||
Reference in New Issue
Block a user