Write

'use strict';
// CRUNCHBASE — Saudi startups, via Serper (same technique as scrape-linkedin-serper.js: search
// engine snippets only, never touching crunchbase.com directly, so none of Crunchbase's own
// paywall/anti-bot defenses come into play at all).
// Yields: company name, Crunchbase URL, one-line description from the snippet. No founder names,
// funding amounts, or emails from this pass — Crunchbase's real data (funding rounds, people) is
// increasingly paywalled/JS-rendered and won't show in a plain search snippet; getting that would
// need actually visiting crunchbase.com, a bigger and riskier follow-on step, not attempted here.
// Usage: node src/scrape-crunchbase-serper.js [outFile] [targetCount]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();

const OUT_FILE = process.argv[2] || path.join(__dirname, '..', 'data', 'crunchbase_saudi_startups.csv');
const TARGET = parseInt(process.argv[3] || '150', 10);
const API_KEY = process.env.SERPER_API_KEY;

const TERMS = ['startup', 'funding', 'seed', 'fintech', 'e-commerce', 'SaaS', 'logistics', 'healthtech', 'app'];
const CITIES = ['Saudi Arabia', 'Riyadh', 'Jeddah', 'Dammam', 'Khobar'];

function buildQueries() {
  const qs = [];
  for (const term of TERMS) for (const city of CITIES) qs.push(`site:crunchbase.com/organization "${city}" "${term}"`);
  return qs.sort(() => Math.random() - 0.5);
}
const QUERIES = buildQueries();

// Entities Crunchbase profiles that aren't really "startups" for our purposes.
const NOT_STARTUP = /\b(ministry|kingdom of saudi arabia|council|authority|program|aramco|university|ministry of)\b/i;

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

function parseTitle(title) {
  return (title || '')
    .replace(/\s*-\s*Crunchbase.*/i, '')
    .replace(/\s*-\s*(Profiles & Contacts|Recent News & Activity|Tech Details|Financial Details|Company Profile & Funding).*/i, '')
    .trim();
}

function baseOrgUrl(link) {
  const m = link.match(/^(https?:\/\/(?:www\.)?crunchbase\.com\/organization\/[^\/?#]+)/i);
  return m ? m[1] : link;
}

async function search(q, page) {
  const res = await fetch('https://google.serper.dev/search', {
    method: 'POST',
    headers: { 'X-API-KEY': API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ q, gl: 'sa', hl: 'en', num: 10, page }),
  });
  if (!res.ok) throw new Error(`Serper HTTP ${res.status}`);
  const data = await res.json();
  return data.organic || [];
}

(async () => {
  if (!API_KEY) { console.error('Missing SERPER_API_KEY in .env'); process.exit(1); }
  const rows = [];
  const seen = new Set();
  outer:
  for (const q of QUERIES) {
    for (let page = 1; page <= 2; page++) {
      let results = [];
      try {
        results = await search(q, page);
      } catch (e) {
        console.log(`query failed "${q}" page=${page}: ${e.message}`);
        break;
      }
      if (!results.length) break;
      for (const r of results) {
        if (!/crunchbase\.com\/organization\//i.test(r.link)) continue;
        const url = baseOrgUrl(r.link);
        if (seen.has(url)) continue;
        const name = parseTitle(r.title);
        if (!name || NOT_STARTUP.test(name) || NOT_STARTUP.test(r.snippet || '')) continue;
        seen.add(url);
        rows.push({ name, url, description: r.snippet || '', query: q });
        console.log(`[${rows.length}] ${name} — ${url}`);
        if (rows.length >= TARGET) break outer;
      }
      await new Promise((r) => setTimeout(r, 300));
    }
  }

  fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
  const cols = ['name', 'url', 'description', 'query'];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} Saudi startups written to ${OUT_FILE}`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });


File created successfully at: /root/ultron/src/scrape-crunchbase-serper.js (file state is current in your context — no need to Read it back)

'use strict';
// CRUNCHBASE — Saudi startups, via Serper (same technique as scrape-linkedin-serper.js: search
// engine snippets only, never touching crunchbase.com directly, so none of Crunchbase's own
// paywall/anti-bot defenses come into play at all).
// Yields: company name, Crunchbase URL, one-line description from the snippet. No founder names,
// funding amounts, or emails from this pass — Crunchbase's real data (funding rounds, people) is
// increasingly paywalled/JS-rendered and won't show in a plain search snippet; getting that would
// need actually visiting crunchbase.com, a bigger and riskier follow-on step, not attempted here.
// Usage: node src/scrape-crunchbase-serper.js [outFile] [targetCount]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();

const OUT_FILE = process.argv[2] || path.join(__dirname, '..', 'data', 'crunchbase_saudi_startups.csv');
const TARGET = parseInt(process.argv[3] || '150', 10);
const API_KEY = process.env.SERPER_API_KEY;

const TERMS = ['startup', 'funding', 'seed', 'fintech', 'e-commerce', 'SaaS', 'logistics', 'healthtech', 'app'];
const CITIES = ['Saudi Arabia', 'Riyadh', 'Jeddah', 'Dammam', 'Khobar'];

function buildQueries() {
  const qs = [];
  for (const term of TERMS) for (const city of CITIES) qs.push(`site:crunchbase.com/organization "${city}" "${term}"`);
  return qs.sort(() => Math.random() - 0.5);
}
const QUERIES = buildQueries();

// Entities Crunchbase profiles that aren't really "startups" for our purposes.
const NOT_STARTUP = /\b(ministry|kingdom of saudi arabia|council|authority|program|aramco|university|ministry of)\b/i;

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

function parseTitle(title) {
  return (title || '')
    .replace(/\s*-\s*Crunchbase.*/i, '')
    .replace(/\s*-\s*(Profiles & Contacts|Recent News & Activity|Tech Details|Financial Details|Company Profile & Funding).*/i, '')
    .trim();
}

function baseOrgUrl(link) {
  const m = link.match(/^(https?:\/\/(?:www\.)?crunchbase\.com\/organization\/[^\/?#]+)/i);
  return m ? m[1] : link;
}

async function search(q, page) {
  const res = await fetch('https://google.serper.dev/search', {
    method: 'POST',
    headers: { 'X-API-KEY': API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ q, gl: 'sa', hl: 'en', num: 10, page }),
  });
  if (!res.ok) throw new Error(`Serper HTTP ${res.status}`);
  const data = await res.json();
  return data.organic || [];
}

(async () => {
  if (!API_KEY) { console.error('Missing SERPER_API_KEY in .env'); process.exit(1); }
  const rows = [];
  const seen = new Set();
  outer:
  for (const q of QUERIES) {
    for (let page = 1; page <= 2; page++) {
      let results = [];
      try {
        results = await search(q, page);
      } catch (e) {
        console.log(`query failed "${q}" page=${page}: ${e.message}`);
        break;
      }
      if (!results.length) break;
      for (const r of results) {
        if (!/crunchbase\.com\/organization\//i.test(r.link)) continue;
        const url = baseOrgUrl(r.link);
        if (seen.has(url)) continue;
        const name = parseTitle(r.title);
        if (!name || NOT_STARTUP.test(name) || NOT_STARTUP.test(r.snippet || '')) continue;
        seen.add(url);
        rows.push({ name, url, description: r.snippet || '', query: q });
        console.log(`[${rows.length}] ${name} — ${url}`);
        if (rows.length >= TARGET) break outer;
      }
      await new Promise((r) => setTimeout(r, 300));
    }
  }

  fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
  const cols = ['name', 'url', 'description', 'query'];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} Saudi startups written to ${OUT_FILE}`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });


Let's test it live:


Bash

cd /root/ultron
timeout 150 node src/scrape-crunchbase-serper.js data/crunchbase_saudi_startups.csv 150 2>&1 | tail -40

Run the Crunchbase Saudi startups scraper targeting 150


[113] FullCycle — https://www.crunchbase.com/organization/fullcycle-cdde
[114] AJEX — https://www.crunchbase.com/organization/ajex
[115] AL Bassami International Group — https://www.crunchbase.com/organization/al-bassami-international-group
[116] PIESHIP — https://www.crunchbase.com/organization/PIEHSIP
[117] Fintech Saudi — https://www.crunchbase.com/organization/fintech-saudi
[118] Azm FinTech — https://www.crunchbase.com/organization/azm-fintech
[119] BwaTech — https://www.crunchbase.com/organization/bwatech
[120] Spare — https://www.crunchbase.com/organization/spare
[121] Wafeer — https://www.crunchbase.com/organization/wafeer
[122] Sulfah — https://www.crunchbase.com/organization/sulfah
[123] LDUN — https://www.crunchbase.com/organization/ldun
[124] Drahim — https://www.crunchbase.com/organization/drahim
[125] Emkan Finance — https://www.crunchbase.com/organization/emkan-finance
[126] Hakbah — https://www.crunchbase.com/organization/hakbah
[127] Snbla — https://www.crunchbase.com/organization/snbla
[128] Tamam — https://www.crunchbase.com/organization/tamam
[129] Auto Trading — https://www.crunchbase.com/organization/automated-trading
[130] Dawul — https://www.crunchbase.com/organization/dawl
[131] Neoleap — https://www.crunchbase.com/organization/neoleap
[132] Autorply — https://www.crunchbase.com/organization/autorply
[133] CyberAgora — https://www.crunchbase.com/organization/cyberagora
[134] Tracking — https://www.crunchbase.com/organization/tracking
[135] Fahis — https://www.crunchbase.com/organization/fahis
[136] Lucidya — https://www.crunchbase.com/organization/lucidya
[137] Blue Ride — https://www.crunchbase.com/organization/blue-ride
[138] munjiz — https://www.crunchbase.com/organization/munjiz
[139] Levers — https://www.crunchbase.com/organization/levers-c75f
[140] Exhibo — https://www.crunchbase.com/organization/exhibo-8160
[141] Growth Outlook - Badr Aqualux — https://www.crunchbase.com/organization/badr-aqualux
[142] Hss Logistics — https://www.crunchbase.com/organization/hss-logistics
[143] Masum Logistics — https://www.crunchbase.com/organization/masum-logistics
[144] Tamer Logistics — https://www.crunchbase.com/organization/tamer-logistics
[145] Groundlines Logistics — https://www.crunchbase.com/organization/groundlines-logistics
[146] Mosanada Logistics Services — https://www.crunchbase.com/organization/mosanada-logistics-services
[147] Nawel — https://www.crunchbase.com/organization/nawel
[148] Arabian Transportation Company — https://www.crunchbase.com/organization/arabian-transportation-company
[149] Red Sea Gateway Terminal — https://www.crunchbase.com/organization/red-sea-gateway-terminal
[150] Binzagr Company — https://www.crunchbase.com/organization/binzagr-company

✅ 150 Saudi startups written to data/crunchbase_saudi_startups.csv


cd /root/ultron
git add src/scrape-crunchbase-serper.js
git commit -m "$(cat <<'EOF'
Add Crunchbase Saudi startup discovery via Serper

Same technique as scrape-linkedin-serper.js: site:crunchbase.com
queries through Serper's Google Search API, never touching
crunchbase.com directly - avoids their own paywall/anti-bot defenses
entirely since it's just public search snippets.

Yields company name + Crunchbase URL + one-line description only.
No founder names/funding/emails - Crunchbase's real data is JS-
rendered/increasingly paywalled and won't show in a search snippet;
that would need actually visiting the site, a bigger, riskier
follow-on not attempted here. Basic keyword filter excludes obvious
government/ministry entries, but Crunchbase profiles plenty of large
established companies alongside true startups - not a pure VC-backed-
startup filter, just company discovery.
EOF
)"
git push origin master 2>&1

Commit and push the Crunchbase Serper scraper


[master 2f905b6] Add Crunchbase Saudi startup discovery via Serper
 1 file changed, 101 insertions(+)
 create mode 100644 src/scrape-crunchbase-serper.js
To github.com:AhmadKhalidSA/ultron.git
   2b1d527..2f905b6  master -> master


Done — 150 Saudi companies from Crunchbase, CSV attached (name, Crunchbase URL, one-line description). Real mix: fintech (Fintech Saudi, Azm FinTech, Emkan Finance, Neoleap), logistics (AJEX, Tamer Logistics, Red Sea Gateway Terminal), plus smaller apps/services (Spare, Wafeer, Sulfah, Munjiz).

Honest caveat: this only pulls what's in the Google snippet — company name + description, no founders/funding/emails. Crunchbase's actual data (people, funding rounds) is behind JS-rendering/paywall that a search snippet can't see; getting that would mean actually visiting crunchbase.com pages, which is a bigger, riskier step (that's the site known for aggressive anti-scraping) — didn't attempt it here.

Also worth knowing: Crunchbase profiles big established companies too, not just VC-backed startups — a few in here (Red Sea Gateway Terminal, Binzagr Company, Arabian Transportation) read more like established firms than "startups." Filtered out obvious government entries but it's not a perfect startup-only filter.

Want emails/LinkedIn for these 150 next (same enrichment approach), or move to the hiring/funding buying-signal search instead?


sent 2 parts (ids: 400, 401)

sent 2 parts (ids: 400, 401)


reacted

reacted


Delivered 150 Saudi companies from Crunchbase via Serper (name, URL, description), same safe technique — never touching crunchbase.com directly. Flagged honestly that founder/funding/email data isn't available this way (JS-rendered/paywalled) and that some entries are established firms rather than pure startups despite the filter. Committed and pushed. Asked whether to enrich these 150 next or move to the hiring/funding buying-signal search.


<channel source="plugin:telegram:telegram" chat_id="798935024" message_id="402" user="798935024" user_id="798935024" ts="2026-07-08T09:32:55.000Z">
What other places that give us numbers and emails
</channel>


Ranked by real hit-rate for Saudi contacts specifically:

1. **Instagram bios** — this was already on Ultron's own roadmap as top priority before we even started the LinkedIn stuff: Saudi small businesses put their phone/WhatsApp directly in the IG bio way more reliably than on a website. We already capture the instagram HANDLE for Maps leads, just never read the bio text itself yet. Probably the single best phone source available.

2. **Company website Contact page — but for phone, not just email.** Our current enrichment (harvestUrl) only extracts email, never phone, even though Saudi business sites usually show a phone/WhatsApp more prominently than email. Quick fix: extend that same function to also regex-match Saudi phone formats. Immediate win, and it helps the EXISTING Maps leads too, not just LinkedIn ones.

3. **Job posting sites (Bayt.com, similar Gulf job boards)** — postings often list a real "email your CV to hr@company.com" — that's a genuine, intentionally-public company email, more reliable than our domain-guessing.

4. **Maroof.sa** — Saudi's own e-commerce trust-verification directory (we already touch this for the Mahally scraper's CR lookup) — listings there are contact-verified by design, since the whole point is consumer trust.

5. Google Maps itself, worth remembering — phone is already our BEST-covered field (2,074/4,446 leads have one). The founders/LinkedIn track is just inherently weaker for phone since individuals aren't Maps listings, only their businesses are.

#1 and #2 are the fastest wins and both improve stuff we already have, not just new founder data. Want me to build the phone-extraction addition first?


sent (id: 403)

sent (id: 403)


Gave a prioritized answer: Instagram bio phone extraction (already the project's own top-priority roadmap item, matches known Saudi business behavior), extending the existing website enrichment to also grab phone numbers (not just email), Bayt.com/Gulf job postings for real HR emails, Maroof.sa's trust-verified directory, and a reminder that Google Maps is already our best phone source — the founders track is just inherently weaker there since individuals aren't Maps listings. Asked if he wants the phone-extraction addition built first since it's the fastest win and benefits existing leads too.


<channel source="plugin:telegram:telegram" chat_id="798935024" message_id="404" user="798935024" user_id="798935024" ts="2026-07-08T09:34:59.000Z">
As far as maaroof does it show phone numbers? Or emails
</channel>


grep -n "maroof" /root/ultron/src/*.js /root/ultron/CLAUDE.md 2>&1

Check existing code/docs mentions of Maroof for context


/root/ultron/src/scrape-mahally.js:4:// name, city, rating, review count, product count, and (often) the CR number + maroof.sa link
/root/ultron/src/scrape-mahally.js:91:  const maroofMatch = text.match(/https?:\/\/maroof\.sa\/\d+/);
/root/ultron/src/scrape-mahally.js:96:    maroof_url: maroofMatch ? maroofMatch[0] : null,
/root/ultron/src/scrape-mahally.js:178:  const header = ['store_name', 'city', 'rating', 'review_count', 'product_count', 'cr_number', 'maroof_url', 'description', 'mahally_url'];
/root/ultron/src/scrape-mahally.js:181:    rows.push(toCsvRow([r.name, r.city, r.rating, r.reviews, r.product_count, r.cr_number, r.maroof_url, r.description, r.url]));
/root/ultron/src/scrape-mahally.js:190:    product_count: r.product_count, cr_number: r.cr_number, maroof_url: r.maroof_url,


grep -o 'maroof\.sa/[0-9]*' /root/ultron/data/*.csv 2>/dev/null | head -3

Find a real maroof.sa URL already captured from Mahally data if any


(Bash completed with no output)


cd /root/ultron
set -a; source .env 2>/dev/null; set +a
node -e "
const { launchBrowser, newPage } = require('./src/browser');
(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);
  await page.goto('https://maroof.sa/', {waitUntil:'networkidle2', timeout: 45000});
  await new Promise(r=>setTimeout(r,2000));
  const title = await page.title();
  console.log('TITLE:', title);
  const bodySample = await page.evaluate(()=>document.body.innerText.slice(0,1200));
  console.log('BODY:', bodySample);
  await browser.close();
})().catch(e=>{console.error('ERR',e.message);process.exit(1)});
" 2>&1

Visit Maroof.sa homepage to understand site structure


TITLE: معروف | الصفحة الرئيسية
BODY: الرئيسية
متاجر معروف
تواصل معنا
تسجيل الدخول
إنشاء حساب
منصة معروف
دليلك لآلاف المتاجر الإلكترونية
تخدم منصة معروف جميع المتعاملين في التجارة الإلكترونية
بحث
المتاجر الأفضل تقييماً
5
(1175 مقيم)
متجر ميرنار
متاجر إلكترونية
5
(279 مقيم)
صفاء وسيطة تسوق
متاجر إلكترونية
5
(388 مقيم)
المترجم الحديث ترجمه طباعه تصوير خدمات الكترونية
حلول إلكترونية
سجل تجارتك
في معروف
منصة معروف تساعدك على انتشار تجارتك الإلكترونية ووصولك لفئتك المستهدفة
مصداقية اعلى
شهادة معتمدة
انتشار اكثر
حمل تطبيق معروف
ابحث، شارك وقيم آلاف المتاجر الإلكترونية

تابعنا
الرئيسية
عن معروف
تواصل معنا
أسئلة شائعة
شروط الاستخدام
سياسة الخصوصية
جميع الحقوق محفوظة © 2026
اصدار : 4.0.129


cd /root/ultron
set -a; source .env 2>/dev/null; set +a
node -e "
const { launchBrowser, newPage } = require('./src/browser');
(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);
  await page.goto('https://maroof.sa/', {waitUntil:'networkidle2', timeout: 45000});
  await new Promise(r=>setTimeout(r,2000));
  const link = await page.evaluate(()=>{
    const a = [...document.querySelectorAll('a')].find(a=>/\/\d+\$/.test(a.href) || /store|merchant|shop/i.test(a.href));
    return a ? a.href : ([...document.querySelectorAll('a')].map(a=>a.href).filter(h=>h.includes('maroof.sa') && h!=='https://maroof.sa/')[0] || null);
  });
  console.log('STORE LINK FOUND:', link);
  if (link) {
    await page.goto(link, {waitUntil:'networkidle2', timeout: 45000});
    await new Promise(r=>setTimeout(r,2000));
    const body = await page.evaluate(()=>document.body.innerText.slice(0,2000));
    console.log('STORE PAGE BODY:', body);
  }
  await browser.close();
})().catch(e=>{console.error('ERR',e.message);process.exit(1)});
" 2>&1

Visit an actual Maroof store detail page to check for phone/email fields


STORE LINK FOUND: null


cd /root/ultron
set -a; source .env 2>/dev/null; set +a
node -e "
const { launchBrowser, newPage } = require('./src/browser');
(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);
  await page.goto('https://maroof.sa/', {waitUntil:'networkidle2', timeout: 45000});
  await new Promise(r=>setTimeout(r,2000));
  const links = await page.evaluate(()=>[...document.querySelectorAll('a')].map(a=>a.href).filter((v,i,a)=>a.indexOf(v)===i));
  console.log(JSON.stringify(links, null, 1));
  await browser.close();
})().catch(e=>{console.error('ERR',e.message);process.exit(1)});
" 2>&1

List all unique links on Maroof homepage


mailto:info@maroof.sa


cd /root/ultron
set -a; source .env 2>/dev/null; set +a
node -e "
const { launchBrowser, newPage } = require('./src/browser');
(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);
  await page.goto('https://maroof.sa/businesses', {waitUntil:'networkidle2', timeout: 45000});
  await new Promise(r=>setTimeout(r,3000));
  const links = await page.evaluate(()=>[...document.querySelectorAll('a')].map(a=>a.href).filter((v,i,a)=>a.indexOf(v)===i));
  console.log('LINKS:', JSON.stringify(links.slice(0,20), null, 1));
  // try clicking the first card-like element
  const clicked = await page.evaluate(() => {
    const candidates = [...document.querySelectorAll('[class*=card], [class*=store], [class*=item]')];
    if (candidates.length) { candidates[0].click(); return candidates[0].className; }
    return null;
  });
  console.log('clicked element class:', clicked);
  await new Promise(r=>setTimeout(r,2500));
  console.log('URL after click:', page.url());
  await browser.close();
})().catch(e=>{console.error('ERR',e.message);process.exit(1)});
" 2>&1

Try /businesses directory page and click a store card to reach detail page


LINKS: []
clicked element class: null
URL after click: https://maroof.sa/businesses


cd /root/ultron
set -a; source .env 2>/dev/null; set +a
node -e "
(async () => {
  const res = await fetch('https://google.serper.dev/search', {
    method: 'POST',
    headers: { 'X-API-KEY': process.env.SERPER_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ q: 'site:maroof.sa', gl: 'sa', hl: 'ar', num: 10 }),
  });
  const data = await res.json();
  console.log(JSON.stringify(data.organic, null, 1));
})();
" 2>&1

Find a real Maroof store detail URL via Serper


معروف | الصفحة الرئيسية

منصة معروف. دليلك لآلاف المتاجر الإلكترونية. تخدم منصة معروف جميع المتعاملين في التجارة الإلكترونية. بحث. سجل تجارتك. في معروف. منصة معروف تساعدك على انتشار ...

أنا تقني - معروف | صفحة المتاجر الإلكترونية

نوع العمل الرئيسي. متجر الكتروني "موقع / تطبيق الكتروني" ; تصنيف العمل. إلكترونيات و إكسسوارات ; نبذه عن العمل. متجر إلكتروني لبيع منتجات متنوعة بأفضل الأسعار.

202780 - معروف | صفحة المتاجر الإلكترونية

معروف | اطلع على جميع المتاجر الإلكترونية في المملكة بكل سهولة عبر خدمة معروف واشتري وأنت مطمن.

متاجر معروف خدمات الأعمال اغصان - معروف | صفحة المتاجر الإلكترونية

متاجر معروف خدمات الأعمال غالي الأثمان - معروف | صفحة المتاجر الإلكترونية

منصة غالي الأثمان منصة تقنية تمكنك من تأسيس متجرك للعطور متكامل مع الموردين وخدمات مساندة. بيانات التواصل. بريد إلكترونيAlathmansa@gmail.com.

متاجر معروف الجمال والصحة عساف - معروف | صفحة المتاجر الإلكترونية

الاسم التجاري. مؤسسة عاسف للتجارة ; رقم السجل التجاري. 7025732855 ; نوع العمل الرئيسي. متجر الكتروني "موقع / تطبيق الكتروني" ; تصنيف العمل. الجمال والصحة ; نبذه عن ...

410 - معروف | صفحة المتاجر الإلكترونية

متاجر معروف أخرى طبيب الكمبيوتر - معروف | صفحة المتاجر الإلكترونية

سياسة الخصوصية - معروف

1. بمجرد زيارة المتصفح لموقع ("معروف")، يقوم الخادم الخاص بالموقع بتسجيل عنوان بروتوكول شبكة الإنترنت IP الخاص به وتاريخ ووقت الزيارة والعنوان URL الخاص بأي ...

متاجر معروف خدمات الأعمال أفكار عصرية

كل مايخص الديكور المنزلي واللوحات الجداريه والابتكارات الجديده في المنزل. بيانات التواصل. بريد إلكترونيabu-saud301@hotmail.com. خدمة عملاء0590712525.

معروف | الصفحة الرئيسية

410 - معروف | صفحة المتاجر الإلكترونية

سياسة الخصوصية - معروف

متاجر معروف خدمات الأعمال أفكار عصرية


Just checked live — yes, BOTH, and it's genuinely good: Maroof listings have a "Contact Info" section with email AND a customer service phone number, and it often shows up directly in the Google snippet without even needing to visit the page. Real examples I just pulled:

"بيانات التواصل. بريد إلكتروني: abu-saud301@hotmail.com. خدمة عملاء: 0590712525" (contact info: email + phone, right there)
"بيانات التواصل. بريد إلكتروني: Alathmansa@gmail.com"

This is actually a better direct-hit-rate source for contact info than LinkedIn or Crunchbase — because these stores WANT to be reachable (it's a trust/verification directory), unlike a CEO's LinkedIn profile.

Want me to build a Maroof scraper next — same Serper site: technique, pulling store name + email + phone directly? This could genuinely beat the founder-track for actual usable contact data.


sent (id: 405)

sent (id: 405)


Confirmed live (not guessed): Maroof.sa listings genuinely show both email and phone in a "Contact Info" section, and it often surfaces directly in Google's snippet without even visiting the page — pulled real examples showing both fields together. This is actually a stronger direct contact-info source than LinkedIn or Crunchbase since these stores want to be reachable. Asked if he wants a Maroof scraper built next using the same Serper technique.


<channel source="plugin:telegram:telegram" chat_id="798935024" message_id="406" user="798935024" user_id="798935024" ts="2026-07-08T09:40:37.000Z">
So we scrape from mahlly their busssniess and the maroof number and then from maroof the rest enrichment?

How much cost is this proxy wise and sraper wise? Lets say for 100 company
</channel>


Actually — good news, we might not even need Mahally for this. Since Maroof's own pages are Google-indexed, we can search `site:maroof.sa` through Serper directly (same trick as LinkedIn/Crunchbase) and skip Mahally's Cloudflare-protected proxy scraping entirely.

Cost for 100 companies, that way:
- Serper: ~10-20 API calls (100 results ÷ ~10 per call) = 10-20 of your 2,500 credits. Basically free.
- Proxy: $0 for any listing where contact info is already in the snippet (like the two examples I showed you). Only listings missing it from the snippet would need an actual page visit — even 100 of those is maybe 30-50MB total (images already blocked), which is a few cents on Evomi's $1/GB pricing.

So: total cost for 100 ≈ a handful of cents, maybe less than $0.10, and barely touches your Serper credits either.

If you specifically want Mahally's extra fields too (ratings, review count, product count — Maroof doesn't have those), then yes, add that step, but it's more proxy usage (100+ Cloudflare-protected page loads) for maybe 20-40 cents more, still cheap, just not free.

Which do you want: (a) Maroof only, direct via Serper, fastest/cheapest, or (b) Mahally discovery + Maroof enrichment, more fields but more proxy?


sent (id: 407)

sent (id: 407)


Reframed the plan: since Maroof's own pages are Google-indexed, we likely don't need Mahally at all — can discover + enrich directly via `site:maroof.sa` through Serper. Gave concrete cost estimates for 100 companies: ~10-20 Serper credits (basically free) and pennies of proxy GB (only for listings missing contact info in the snippet). Offered the Mahally+Maroof combo as an alternative if he wants Mahally's extra fields (ratings/reviews/product count), noting it costs a bit more proxy but still cheap. Asked which pipeline he wants.


<channel source="plugin:telegram:telegram" chat_id="798935024" message_id="408" user="798935024" user_id="798935024" ts="2026-07-08T10:03:30.000Z">
I want mahlly to help us filter by product or service i dont think maroof let us do this
</channel>


reacted

reacted


cd /root/ultron
set -a; source .env 2>/dev/null; set +a
node -e "
const { launchBrowser, newPage } = require('./src/browser');
(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);
  await page.goto('https://maroof.sa/88357', {waitUntil:'networkidle2', timeout: 45000});
  await new Promise(r=>setTimeout(r,2500));
  const body = await page.evaluate(()=>document.body.innerText);
  console.log(body);
  await browser.close();
})().catch(e=>{console.error('ERR',e.message);process.exit(1)});
" 2>&1

Visit a real Maroof store page directly to check full contact info structure


cd /root/ultron
set -a; source .env 2>/dev/null; set +a
node -e "
const { launchBrowser, newPage } = require('./src/browser');
(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);
  const resp = await page.goto('https://maroof.sa/88357', {waitUntil:'networkidle2', timeout: 45000});
  console.log('STATUS:', resp.status());
  await new Promise(r=>setTimeout(r,5000));
  const bodyLen = await page.evaluate(()=>document.body.innerHTML.length);
  console.log('BODY HTML LENGTH:', bodyLen);
  const title = await page.title();
  console.log('TITLE:', title);
  const text = await page.evaluate(()=>document.body.innerText);
  console.log('TEXT LEN:', text.length);
  console.log('TEXT:', text.slice(0,1500));
  await browser.close();
})().catch(e=>{console.error('ERR',e.message);process.exit(1)});
" 2>&1

Debug why the Maroof store detail page rendered empty


STATUS: 200
BODY HTML LENGTH: 225
TITLE: معروف
TEXT LEN: 0
TEXT:


cd /root/ultron
set -a; source .env 2>/dev/null; set +a
node -e "
const { launchBrowser, newPage } = require('./src/browser');
(async () => {
  const browser = await launchBrowser();
  const page = await newPage(browser);
  await page.goto('https://maroof.sa/', {waitUntil:'networkidle2', timeout: 45000});
  await new Promise(r=>setTimeout(r,1500));
  await page.evaluate(() => { window.history.pushState({}, '', '/88357'); window.dispatchEvent(new Event('popstate')); });
  await new Promise(r=>setTimeout(r,4000));
  let text = await page.evaluate(()=>document.body.innerText);
  console.log('AFTER PUSHSTATE, TEXT LEN:', text.length);
  if (!text.length) {
    await page.goto('https://maroof.sa/88357', {waitUntil:'load', timeout: 45000});
    await new Promise(r=>setTimeout(r,8000));
    text = await page.evaluate(()=>document.body.innerText);
    console.log('AFTER LONG WAIT DIRECT NAV, TEXT LEN:', text.length);
  }
  console.log(text.slice(0,1500));
  await browser.close();
})().catch(e=>{console.error('ERR',e.message);process.exit(1)});
" 2>&1

Try client-side navigation and longer wait to render the Maroof detail page


AFTER PUSHSTATE, TEXT LEN: 1073
الرئيسية
متاجر معروف
تواصل معنا
تسجيل الدخول
إنشاء حساب
متاجر معروف
خدمات الأعمال
أفكار عصرية
2.3
أفكار عصرية
Afkar modern
متاجر إلكترونية
رقم معروف   88357
مشاركة المتجر
ــــــــ صحة جميع المعلومات الخاصة بالعمل تقع تحت مسؤولية صاحب العمل ــــــــ
بيانات العمل
سياسة الاستبدال والاسترجاع
الاسم التجاري
مؤسسة أفكار عصرية للتجارة والتسويق
رقم السجل التجاري
7003469587
نوع العمل الرئيسي
متجر الكتروني "موقع / تطبيق الكتروني"
تصنيف العمل
متاجر إلكترونية
نبذه عن العمل
كل مايخص الديكور المنزلي واللوحات الجداريه والابتكارات الجديده في المنزل
بيانات التواصل
بريد إلكتروني
abu-saud301@hotmail.com
خدمة عملاء
0590712525
المنصات الإلكترونية
موقع ويب
إنستقرام
بيانات العنوان
الجمال
- الرياض - منطقة الرياض
التقييمات والمراجعات
قم بكتابة مراجعة لهذا المتجر
شارك آراءك مع الآخرين
أضف تقييم
2.3
(*)
(*)
(*)
( )
( )
إجمالي التقييمات 3
1
2
3
4
5
متاجر مشابهة
4.6
(1555 مقيم)
نايس ون
خدمات الأعمال
5
(1205 مقيم)
متجر او ان
خدمات الأعمال
5
(1175 مقيم)
متجر ميرنار
خدمات الأعمال
تابعنا
الرئيسية
عن معروف
تواصل معنا
أسئلة شائعة
شروط الاستخدام
سياسة الخصوصية
جميع الحقوق محفوظة © 2026
اصدار :


Write

'use strict';
// MAROOF enrichment — takes a scrape-mahally.js CSV (has a maroof_url column when the store's
// description mentioned one) and visits each Maroof listing for its real contact info: email,
// customer-service phone, legal business name, CR number, category, city.
//
// Maroof is a client-rendered SPA that does NOT render correctly on a direct hard navigation to
// a deep link (e.g. https://maroof.sa/88357 loads an empty shell) - confirmed live 2026-07-08.
// Fix: load the homepage first, then client-side-navigate via history.pushState + a popstate
// event (mimics an in-app click) before reading the page. Uses the existing proxy+stealth
// browser (images/media/fonts already blocked in browser.js's newPage()).
//
// Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();
const { launchBrowser, newPage } = require('./browser');

const IN_FILE = process.argv[2];
const OUT_FILE = process.argv[3] || (IN_FILE ? IN_FILE.replace(/\.csv$/, '') + '_maroof.csv' : null);
if (!IN_FILE || !OUT_FILE) { console.error('Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]'); process.exit(1); }

function parseCsv(text) {
  const rows = []; let row = [], field = '', inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') inQuotes = false;
      else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
    else if (c !== '\r') field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const header = rows.shift();
  return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] || ''])));
}

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  await new Promise((r) => setTimeout(r, 3500));
  return page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
  });
}

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
  const browser = await launchBrowser();
  let enriched = 0, emailsFound = 0, phonesFound = 0;
  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});
  }

  const cols = [...Object.keys(rows[0] || {})];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} rows written to ${OUT_FILE}`);
  console.log(`Enriched: ${enriched} had a maroof_url and loaded · ${emailsFound} emails · ${phonesFound} phones`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });


File created successfully at: /root/ultron/src/enrich-maroof.js (file state is current in your context — no need to Read it back)

'use strict';
// MAROOF enrichment — takes a scrape-mahally.js CSV (has a maroof_url column when the store's
// description mentioned one) and visits each Maroof listing for its real contact info: email,
// customer-service phone, legal business name, CR number, category, city.
//
// Maroof is a client-rendered SPA that does NOT render correctly on a direct hard navigation to
// a deep link (e.g. https://maroof.sa/88357 loads an empty shell) - confirmed live 2026-07-08.
// Fix: load the homepage first, then client-side-navigate via history.pushState + a popstate
// event (mimics an in-app click) before reading the page. Uses the existing proxy+stealth
// browser (images/media/fonts already blocked in browser.js's newPage()).
//
// Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();
const { launchBrowser, newPage } = require('./browser');

const IN_FILE = process.argv[2];
const OUT_FILE = process.argv[3] || (IN_FILE ? IN_FILE.replace(/\.csv$/, '') + '_maroof.csv' : null);
if (!IN_FILE || !OUT_FILE) { console.error('Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]'); process.exit(1); }

function parseCsv(text) {
  const rows = []; let row = [], field = '', inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') inQuotes = false;
      else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
    else if (c !== '\r') field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const header = rows.shift();
  return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] || ''])));
}

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  await new Promise((r) => setTimeout(r, 3500));
  return page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
  });
}

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
  const browser = await launchBrowser();
  let enriched = 0, emailsFound = 0, phonesFound = 0;
  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});
  }

  const cols = [...Object.keys(rows[0] || {})];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} rows written to ${OUT_FILE}`);
  console.log(`Enriched: ${enriched} had a maroof_url and loaded · ${emailsFound} emails · ${phonesFound} phones`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });


Bash

cd /root/ultron
timeout 240 node src/scrape-mahally.js data/mahally_test.csv 30 60 2>&1 | tail -40

Run a small Mahally batch to get real test data with maroof_url


Command running in background with ID: bvr1e1jbt. Output is being written to: /tmp/claude-0/-root-ultron/e76b8acc-b39e-441c-ad82-68c4ed982296/tasks/bvr1e1jbt.output. You will be notified when it completes. To check interim output, use Read on that file path.


Running a small Mahally test batch in the background to get real data to validate the Maroof enrichment against — I'll report back once it completes.


<task-notification>
<task-id>bvr1e1jbt</task-id>
<tool-use-id>toolu_01A7hVHhGG14ETZq1hUmfxEt</tool-use-id>
<output-file>/tmp/claude-0/-root-ultron/e76b8acc-b39e-441c-ad82-68c4ed982296/tasks/bvr1e1jbt.output</output-file>
<status>failed</status>
<summary>Background command "Run a small Mahally batch to get real test data with maroof_url" failed with exit code 143</summary>
</task-notification>


tail -50 /tmp/claude-0/-root-ultron/e76b8acc-b39e-441c-ad82-68c4ed982296/tasks/bvr1e1jbt.output 2>&1
echo "---csv check---"
ls -la /root/ultron/data/mahally_test.csv* 2>&1
cat /root/ultron/data/mahally_test.csv 2>&1 | head -20

Check Mahally test run output and any partial CSV produced


Terminated
---csv check---
ls: cannot access '/root/ultron/data/mahally_test.csv*': No such file or directory
cat: /root/ultron/data/mahally_test.csv: No such file or directory


Write

store_name,city,rating,review_count,product_count,cr_number,maroof_url,description,mahally_url
أفكار عصرية,Riyadh,2.3,3,,,https://maroof.sa/88357,test row,https://mahally.com/ar/stores/test1
غالي الأثمان,Riyadh,,,,,https://maroof.sa/118366,test row,https://mahally.com/ar/stores/test2
عاسف للجمال,Riyadh,,,,,https://maroof.sa/268249,test row,https://mahally.com/ar/stores/test3


File created successfully at: /root/ultron/data/mahally_synthetic_test.csv (file state is current in your context — no need to Read it back)

store_name,city,rating,review_count,product_count,cr_number,maroof_url,description,mahally_url
أفكار عصرية,Riyadh,2.3,3,,,https://maroof.sa/88357,test row,https://mahally.com/ar/stores/test1
غالي الأثمان,Riyadh,,,,,https://maroof.sa/118366,test row,https://mahally.com/ar/stores/test2
عاسف للجمال,Riyadh,,,,,https://maroof.sa/268249,test row,https://mahally.com/ar/stores/test3


Bash

cd /root/ultron
timeout 90 node src/enrich-maroof.js data/mahally_synthetic_test.csv data/mahally_synthetic_test_maroof.csv 2>&1

Validate the new Maroof enrichment script against known real listings


Loaded 3 rows from data/mahally_synthetic_test.csv
[no data] أفكار عصرية — maroof/88357
[no data] غالي الأثمان — maroof/118366
[1] عاسف للجمال — support@3safsa.com — 0554663060

✅ 3 rows written to data/mahally_synthetic_test_maroof.csv
Enriched: 1 had a maroof_url and loaded · 1 emails · 1 phones


Edit

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  await new Promise((r) => setTimeout(r, 3500));
  return page.evaluate(() => {

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  // poll instead of a fixed wait — the SPA's render time varies run to run
  const deadline = Date.now() + 9000;
  while (Date.now() < deadline) {
    const ready = await page.evaluate(() => document.body.innerText.includes('بيانات التواصل') || document.body.innerText.includes('لا يوجد'));
    if (ready) break;
    await new Promise((r) => setTimeout(r, 500));
  }
  return page.evaluate(() => {


The file /root/ultron/src/enrich-maroof.js has been updated successfully. (file state is current in your context — no need to Read it back)

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  // poll instead of a fixed wait — the SPA's render time varies run to run
  const deadline = Date.now() + 9000;
  while (Date.now() < deadline) {
    const ready = await page.evaluate(() => document.body.innerText.includes('بيانات التواصل') || document.body.innerText.includes('لا يوجد'));
    if (ready) break;
    await new Promise((r) => setTimeout(r, 500));
  }
  return page.evaluate(() => {

'use strict';
// MAROOF enrichment — takes a scrape-mahally.js CSV (has a maroof_url column when the store's
// description mentioned one) and visits each Maroof listing for its real contact info: email,
// customer-service phone, legal business name, CR number, category, city.
//
// Maroof is a client-rendered SPA that does NOT render correctly on a direct hard navigation to
// a deep link (e.g. https://maroof.sa/88357 loads an empty shell) - confirmed live 2026-07-08.
// Fix: load the homepage first, then client-side-navigate via history.pushState + a popstate
// event (mimics an in-app click) before reading the page. Uses the existing proxy+stealth
// browser (images/media/fonts already blocked in browser.js's newPage()).
//
// Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();
const { launchBrowser, newPage } = require('./browser');

const IN_FILE = process.argv[2];
const OUT_FILE = process.argv[3] || (IN_FILE ? IN_FILE.replace(/\.csv$/, '') + '_maroof.csv' : null);
if (!IN_FILE || !OUT_FILE) { console.error('Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]'); process.exit(1); }

function parseCsv(text) {
  const rows = []; let row = [], field = '', inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') inQuotes = false;
      else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
    else if (c !== '\r') field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const header = rows.shift();
  return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] || ''])));
}

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  await new Promise((r) => setTimeout(r, 3500));
  return page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
  });
}

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
  const browser = await launchBrowser();
  let enriched = 0, emailsFound = 0, phonesFound = 0;
  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});
  }

  const cols = [...Object.keys(rows[0] || {})];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} rows written to ${OUT_FILE}`);
  console.log(`Enriched: ${enriched} had a maroof_url and loaded · ${emailsFound} emails · ${phonesFound} phones`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });

-  await new Promise((r) => setTimeout(r, 3500));

+  // poll instead of a fixed wait — the SPA's render time varies run to run

+    const ready = await page.evaluate(() => document.body.innerText.includes('بيانات التواصل') || document.body.innerText.includes('لا يوجد'));

+    await new Promise((r) => setTimeout(r, 500));


Bash

cd /root/ultron
timeout 90 node src/enrich-maroof.js data/mahally_synthetic_test.csv data/mahally_synthetic_test_maroof.csv 2>&1

Re-test the Maroof enrichment with polling-based wait fix


Loaded 3 rows from data/mahally_synthetic_test.csv
[1] أفكار عصرية — المنصات الإلكترونية — (no phone)
[2] غالي الأثمان — abu-saud301@hotmail.com — 0590712525
[3] عاسف للجمال — Alathmansa@gmail.com — 0545550557

✅ 3 rows written to data/mahally_synthetic_test_maroof.csv
Enriched: 3 had a maroof_url and loaded · 3 emails · 2 phones


Edit

  // poll instead of a fixed wait — the SPA's render time varies run to run
  const deadline = Date.now() + 9000;
  while (Date.now() < deadline) {
    const ready = await page.evaluate(() => document.body.innerText.includes('بيانات التواصل') || document.body.innerText.includes('لا يوجد'));
    if (ready) break;
    await new Promise((r) => setTimeout(r, 500));
  }

  // Poll for THIS listing's own id string in the page ("رقم معروف   <id>") rather than any
  // generic "contact info" label - the old listing's DOM lingers briefly during the SPA
  // transition and a generic check reads stale data from the PREVIOUS row otherwise.
  const deadline = Date.now() + 9000;
  while (Date.now() < deadline) {
    const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
    if (ready) break;
    await new Promise((r) => setTimeout(r, 400));
  }
  await new Promise((r) => setTimeout(r, 500)); // let the rest of the panel finish painting


  // Poll for THIS listing's own id string in the page ("رقم معروف   <id>") rather than any
  // generic "contact info" label - the old listing's DOM lingers briefly during the SPA
  // transition and a generic check reads stale data from the PREVIOUS row otherwise.
  const deadline = Date.now() + 9000;
  while (Date.now() < deadline) {
    const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
    if (ready) break;
    await new Promise((r) => setTimeout(r, 400));
  }
  await new Promise((r) => setTimeout(r, 500)); // let the rest of the panel finish painting

'use strict';
// MAROOF enrichment — takes a scrape-mahally.js CSV (has a maroof_url column when the store's
// description mentioned one) and visits each Maroof listing for its real contact info: email,
// customer-service phone, legal business name, CR number, category, city.
//
// Maroof is a client-rendered SPA that does NOT render correctly on a direct hard navigation to
// a deep link (e.g. https://maroof.sa/88357 loads an empty shell) - confirmed live 2026-07-08.
// Fix: load the homepage first, then client-side-navigate via history.pushState + a popstate
// event (mimics an in-app click) before reading the page. Uses the existing proxy+stealth
// browser (images/media/fonts already blocked in browser.js's newPage()).
//
// Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();
const { launchBrowser, newPage } = require('./browser');

const IN_FILE = process.argv[2];
const OUT_FILE = process.argv[3] || (IN_FILE ? IN_FILE.replace(/\.csv$/, '') + '_maroof.csv' : null);
if (!IN_FILE || !OUT_FILE) { console.error('Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]'); process.exit(1); }

function parseCsv(text) {
  const rows = []; let row = [], field = '', inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') inQuotes = false;
      else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
    else if (c !== '\r') field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const header = rows.shift();
  return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] || ''])));
}

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  // poll instead of a fixed wait — the SPA's render time varies run to run
  const deadline = Date.now() + 9000;
  while (Date.now() < deadline) {
    const ready = await page.evaluate(() => document.body.innerText.includes('بيانات التواصل') || document.body.innerText.includes('لا يوجد'));
    if (ready) break;
    await new Promise((r) => setTimeout(r, 500));
  }
  return page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
  });
}

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
  const browser = await launchBrowser();
  let enriched = 0, emailsFound = 0, phonesFound = 0;
  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});
  }

  const cols = [...Object.keys(rows[0] || {})];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} rows written to ${OUT_FILE}`);
  console.log(`Enriched: ${enriched} had a maroof_url and loaded · ${emailsFound} emails · ${phonesFound} phones`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });

-  // poll instead of a fixed wait — the SPA's render time varies run to run

+  // Poll for THIS listing's own id string in the page ("رقم معروف   <id>") rather than any

+  // generic "contact info" label - the old listing's DOM lingers briefly during the SPA

+  // transition and a generic check reads stale data from the PREVIOUS row otherwise.

-    const ready = await page.evaluate(() => document.body.innerText.includes('بيانات التواصل') || document.body.innerText.includes('لا يوجد'));

+    const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);

-    await new Promise((r) => setTimeout(r, 500));

+    await new Promise((r) => setTimeout(r, 400));

+  await new Promise((r) => setTimeout(r, 500)); // let the rest of the panel finish painting


Let's re-test to confirm the fix:


Bash

cd /root/ultron
timeout 90 node src/enrich-maroof.js data/mahally_synthetic_test.csv data/mahally_synthetic_test_maroof.csv 2>&1

Re-verify Maroof enrichment with the row-shift fix


Edit

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  // Poll for THIS listing's own id string in the page ("رقم معروف   <id>") rather than any
  // generic "contact info" label - the old listing's DOM lingers briefly during the SPA
  // transition and a generic check reads stale data from the PREVIOUS row otherwise.
  const deadline = Date.now() + 9000;
  while (Date.now() < deadline) {
    const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
    if (ready) break;
    await new Promise((r) => setTimeout(r, 400));
  }
  await new Promise((r) => setTimeout(r, 500)); // let the rest of the panel finish painting
  return page.evaluate(() => {

// A direct hard-goto to a deep link (https://maroof.sa/<id>) loads an empty shell - confirmed
// live. A client-side pushState navigation reused across multiple listings on one long-lived
// page also proved unreliable (stale previous-listing content lingers and gets read instead of
// the new one - confirmed live too). The one sequence that reliably works: fresh page, load the
// homepage, THEN pushState once. So each listing gets its own fresh page/homepage load - slightly
// slower, but correct every time, and cost is trivial (text-only, images already blocked).
async function scrapeListing(browser, id) {
  const page = await newPage(browser);
  try {
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1200));
    await page.evaluate((storeId) => {
      window.history.pushState({}, '', '/' + storeId);
      window.dispatchEvent(new Event('popstate'));
    }, id);
    const deadline = Date.now() + 9000;
    while (Date.now() < deadline) {
      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
      if (ready) break;
      await new Promise((r) => setTimeout(r, 400));
    }
    await new Promise((r) => setTimeout(r, 500));
    return await page.evaluate(() => {


// A direct hard-goto to a deep link (https://maroof.sa/<id>) loads an empty shell - confirmed
// live. A client-side pushState navigation reused across multiple listings on one long-lived
// page also proved unreliable (stale previous-listing content lingers and gets read instead of
// the new one - confirmed live too). The one sequence that reliably works: fresh page, load the
// homepage, THEN pushState once. So each listing gets its own fresh page/homepage load - slightly
// slower, but correct every time, and cost is trivial (text-only, images already blocked).
async function scrapeListing(browser, id) {
  const page = await newPage(browser);
  try {
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1200));
    await page.evaluate((storeId) => {
      window.history.pushState({}, '', '/' + storeId);
      window.dispatchEvent(new Event('popstate'));
    }, id);
    const deadline = Date.now() + 9000;
    while (Date.now() < deadline) {
      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
      if (ready) break;
      await new Promise((r) => setTimeout(r, 400));
    }
    await new Promise((r) => setTimeout(r, 500));
    return await page.evaluate(() => {

'use strict';
// MAROOF enrichment — takes a scrape-mahally.js CSV (has a maroof_url column when the store's
// description mentioned one) and visits each Maroof listing for its real contact info: email,
// customer-service phone, legal business name, CR number, category, city.
//
// Maroof is a client-rendered SPA that does NOT render correctly on a direct hard navigation to
// a deep link (e.g. https://maroof.sa/88357 loads an empty shell) - confirmed live 2026-07-08.
// Fix: load the homepage first, then client-side-navigate via history.pushState + a popstate
// event (mimics an in-app click) before reading the page. Uses the existing proxy+stealth
// browser (images/media/fonts already blocked in browser.js's newPage()).
//
// Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();
const { launchBrowser, newPage } = require('./browser');

const IN_FILE = process.argv[2];
const OUT_FILE = process.argv[3] || (IN_FILE ? IN_FILE.replace(/\.csv$/, '') + '_maroof.csv' : null);
if (!IN_FILE || !OUT_FILE) { console.error('Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]'); process.exit(1); }

function parseCsv(text) {
  const rows = []; let row = [], field = '', inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') inQuotes = false;
      else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
    else if (c !== '\r') field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const header = rows.shift();
  return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] || ''])));
}

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

async function scrapeListing(page, id) {
  await page.evaluate((storeId) => {
    window.history.pushState({}, '', '/' + storeId);
    window.dispatchEvent(new Event('popstate'));
  }, id);
  // Poll for THIS listing's own id string in the page ("رقم معروف   <id>") rather than any
  // generic "contact info" label - the old listing's DOM lingers briefly during the SPA
  // transition and a generic check reads stale data from the PREVIOUS row otherwise.
  const deadline = Date.now() + 9000;
  while (Date.now() < deadline) {
    const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
    if (ready) break;
    await new Promise((r) => setTimeout(r, 400));
  }
  await new Promise((r) => setTimeout(r, 500)); // let the rest of the panel finish painting
  return page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
  });
}

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
  const browser = await launchBrowser();
  let enriched = 0, emailsFound = 0, phonesFound = 0;
  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});
  }

  const cols = [...Object.keys(rows[0] || {})];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} rows written to ${OUT_FILE}`);
  console.log(`Enriched: ${enriched} had a maroof_url and loaded · ${emailsFound} emails · ${phonesFound} phones`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });

-async function scrapeListing(page, id) {

-    window.history.pushState({}, '', '/' + storeId);

-    window.dispatchEvent(new Event('popstate'));

-  // Poll for THIS listing's own id string in the page ("رقم معروف   <id>") rather than any

-  // generic "contact info" label - the old listing's DOM lingers briefly during the SPA

-  // transition and a generic check reads stale data from the PREVIOUS row otherwise.

-    const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);

-    await new Promise((r) => setTimeout(r, 400));

-  await new Promise((r) => setTimeout(r, 500)); // let the rest of the panel finish painting

+// A direct hard-goto to a deep link (https://maroof.sa/<id>) loads an empty shell - confirmed

+// live. A client-side pushState navigation reused across multiple listings on one long-lived

+// page also proved unreliable (stale previous-listing content lingers and gets read instead of

+// the new one - confirmed live too). The one sequence that reliably works: fresh page, load the

+// homepage, THEN pushState once. So each listing gets its own fresh page/homepage load - slightly

+// slower, but correct every time, and cost is trivial (text-only, images already blocked).

+async function scrapeListing(browser, id) {

+    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });

+    await new Promise((r) => setTimeout(r, 1200));

+      window.history.pushState({}, '', '/' + storeId);

+      window.dispatchEvent(new Event('popstate'));

+    const deadline = Date.now() + 9000;

+      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);

+      await new Promise((r) => setTimeout(r, 400));

+    await new Promise((r) => setTimeout(r, 500));


Bash

grep -n "async function scrapeListing\|^}" /root/ultron/src/enrich-maroof.js | head -10

Check function structure for bracket fixing


21:})();
44:}
50:}
58:async function scrapeListing(browser, id) {
90:}
131:})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });


58	async function scrapeListing(browser, id) {
59	  const page = await newPage(browser);
60	  try {
61	    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
62	    await new Promise((r) => setTimeout(r, 1200));
63	    await page.evaluate((storeId) => {
64	      window.history.pushState({}, '', '/' + storeId);
65	      window.dispatchEvent(new Event('popstate'));
66	    }, id);
67	    const deadline = Date.now() + 9000;
68	    while (Date.now() < deadline) {
69	      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
70	      if (ready) break;
71	      await new Promise((r) => setTimeout(r, 400));
72	    }
73	    await new Promise((r) => setTimeout(r, 500));
74	    return await page.evaluate(() => {
75	    const text = document.body.innerText;
76	    const get = (label) => {
77	      const i = text.indexOf(label);
78	      if (i === -1) return '';
79	      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
80	    };
81	    return {
82	      legal_name: get('الاسم التجاري'),
83	      cr_number: get('رقم السجل التجاري'),
84	      category: get('تصنيف العمل'),
85	      email: get('بريد إلكتروني'),
86	      phone: get('خدمة عملاء'),
87	      found: /بيانات التواصل/.test(text),
88	    };
89	  });

async function scrapeListing(browser, id) {
  const page = await newPage(browser);
  try {
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1200));
    await page.evaluate((storeId) => {
      window.history.pushState({}, '', '/' + storeId);
      window.dispatchEvent(new Event('popstate'));
    }, id);
    const deadline = Date.now() + 9000;
    while (Date.now() < deadline) {
      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
      if (ready) break;
      await new Promise((r) => setTimeout(r, 400));
    }
    await new Promise((r) => setTimeout(r, 500));
    return await page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
  });


Need to close the try block and add a finally to close the page. Let me fix the rest:


Edit

    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
  });

    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
    });
  } finally {
    await page.close().catch(() => {});
  }
}


    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
    });
  } finally {
    await page.close().catch(() => {});
  }
}

'use strict';
// MAROOF enrichment — takes a scrape-mahally.js CSV (has a maroof_url column when the store's
// description mentioned one) and visits each Maroof listing for its real contact info: email,
// customer-service phone, legal business name, CR number, category, city.
//
// Maroof is a client-rendered SPA that does NOT render correctly on a direct hard navigation to
// a deep link (e.g. https://maroof.sa/88357 loads an empty shell) - confirmed live 2026-07-08.
// Fix: load the homepage first, then client-side-navigate via history.pushState + a popstate
// event (mimics an in-app click) before reading the page. Uses the existing proxy+stealth
// browser (images/media/fonts already blocked in browser.js's newPage()).
//
// Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();
const { launchBrowser, newPage } = require('./browser');

const IN_FILE = process.argv[2];
const OUT_FILE = process.argv[3] || (IN_FILE ? IN_FILE.replace(/\.csv$/, '') + '_maroof.csv' : null);
if (!IN_FILE || !OUT_FILE) { console.error('Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]'); process.exit(1); }

function parseCsv(text) {
  const rows = []; let row = [], field = '', inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') inQuotes = false;
      else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
    else if (c !== '\r') field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const header = rows.shift();
  return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] || ''])));
}

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

// A direct hard-goto to a deep link (https://maroof.sa/<id>) loads an empty shell - confirmed
// live. A client-side pushState navigation reused across multiple listings on one long-lived
// page also proved unreliable (stale previous-listing content lingers and gets read instead of
// the new one - confirmed live too). The one sequence that reliably works: fresh page, load the
// homepage, THEN pushState once. So each listing gets its own fresh page/homepage load - slightly
// slower, but correct every time, and cost is trivial (text-only, images already blocked).
async function scrapeListing(browser, id) {
  const page = await newPage(browser);
  try {
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1200));
    await page.evaluate((storeId) => {
      window.history.pushState({}, '', '/' + storeId);
      window.dispatchEvent(new Event('popstate'));
    }, id);
    const deadline = Date.now() + 9000;
    while (Date.now() < deadline) {
      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
      if (ready) break;
      await new Promise((r) => setTimeout(r, 400));
    }
    await new Promise((r) => setTimeout(r, 500));
    return await page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
  });
}

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
  const browser = await launchBrowser();
  let enriched = 0, emailsFound = 0, phonesFound = 0;
  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});
  }

  const cols = [...Object.keys(rows[0] || {})];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} rows written to ${OUT_FILE}`);
  console.log(`Enriched: ${enriched} had a maroof_url and loaded · ${emailsFound} emails · ${phonesFound} phones`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });

+    await page.close().catch(() => {});


58	async function scrapeListing(browser, id) {
59	  const page = await newPage(browser);
60	  try {
61	    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
62	    await new Promise((r) => setTimeout(r, 1200));
63	    await page.evaluate((storeId) => {
64	      window.history.pushState({}, '', '/' + storeId);
65	      window.dispatchEvent(new Event('popstate'));
66	    }, id);
67	    const deadline = Date.now() + 9000;
68	    while (Date.now() < deadline) {
69	      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
70	      if (ready) break;
71	      await new Promise((r) => setTimeout(r, 400));
72	    }
73	    await new Promise((r) => setTimeout(r, 500));
74	    return await page.evaluate(() => {
75	    const text = document.body.innerText;
76	    const get = (label) => {
77	      const i = text.indexOf(label);
78	      if (i === -1) return '';
79	      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
80	    };
81	    return {
82	      legal_name: get('الاسم التجاري'),
83	      cr_number: get('رقم السجل التجاري'),
84	      category: get('تصنيف العمل'),
85	      email: get('بريد إلكتروني'),
86	      phone: get('خدمة عملاء'),
87	      found: /بيانات التواصل/.test(text),
88	    };
89	    });
90	  } finally {
91	    await page.close().catch(() => {});
92	  }
93	}
94	}
95
96	(async () => {
97	  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));

async function scrapeListing(browser, id) {
  const page = await newPage(browser);
  try {
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1200));
    await page.evaluate((storeId) => {
      window.history.pushState({}, '', '/' + storeId);
      window.dispatchEvent(new Event('popstate'));
    }, id);
    const deadline = Date.now() + 9000;
    while (Date.now() < deadline) {
      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
      if (ready) break;
      await new Promise((r) => setTimeout(r, 400));
    }
    await new Promise((r) => setTimeout(r, 500));
    return await page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
    });
  } finally {
    await page.close().catch(() => {});
  }
}
}

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));


There's a stray extra closing brace on line 94. Let me remove it:


  } finally {
    await page.close().catch(() => {});
  }
}
}

(async () => {

  } finally {
    await page.close().catch(() => {});
  }
}

(async () => {


  } finally {
    await page.close().catch(() => {});
  }
}

(async () => {

'use strict';
// MAROOF enrichment — takes a scrape-mahally.js CSV (has a maroof_url column when the store's
// description mentioned one) and visits each Maroof listing for its real contact info: email,
// customer-service phone, legal business name, CR number, category, city.
//
// Maroof is a client-rendered SPA that does NOT render correctly on a direct hard navigation to
// a deep link (e.g. https://maroof.sa/88357 loads an empty shell) - confirmed live 2026-07-08.
// Fix: load the homepage first, then client-side-navigate via history.pushState + a popstate
// event (mimics an in-app click) before reading the page. Uses the existing proxy+stealth
// browser (images/media/fonts already blocked in browser.js's newPage()).
//
// Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]
const fs = require('fs'), path = require('path');
(function () {
  const p = path.join(__dirname, '..', '.env');
  if (!fs.existsSync(p)) return;
  for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
    const t = l.trim(); if (!t || t.startsWith('#')) continue;
    const i = t.indexOf('='); if (i > -1 && !(t.slice(0, i).trim() in process.env)) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
  }
})();
const { launchBrowser, newPage } = require('./browser');

const IN_FILE = process.argv[2];
const OUT_FILE = process.argv[3] || (IN_FILE ? IN_FILE.replace(/\.csv$/, '') + '_maroof.csv' : null);
if (!IN_FILE || !OUT_FILE) { console.error('Usage: node src/enrich-maroof.js <mahallyFile.csv> [outFile.csv]'); process.exit(1); }

function parseCsv(text) {
  const rows = []; let row = [], field = '', inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') inQuotes = false;
      else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
    else if (c !== '\r') field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const header = rows.shift();
  return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] || ''])));
}

function esc(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

// A direct hard-goto to a deep link (https://maroof.sa/<id>) loads an empty shell - confirmed
// live. A client-side pushState navigation reused across multiple listings on one long-lived
// page also proved unreliable (stale previous-listing content lingers and gets read instead of
// the new one - confirmed live too). The one sequence that reliably works: fresh page, load the
// homepage, THEN pushState once. So each listing gets its own fresh page/homepage load - slightly
// slower, but correct every time, and cost is trivial (text-only, images already blocked).
async function scrapeListing(browser, id) {
  const page = await newPage(browser);
  try {
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1200));
    await page.evaluate((storeId) => {
      window.history.pushState({}, '', '/' + storeId);
      window.dispatchEvent(new Event('popstate'));
    }, id);
    const deadline = Date.now() + 9000;
    while (Date.now() < deadline) {
      const ready = await page.evaluate((storeId) => document.body.innerText.includes(storeId), id);
      if (ready) break;
      await new Promise((r) => setTimeout(r, 400));
    }
    await new Promise((r) => setTimeout(r, 500));
    return await page.evaluate(() => {
    const text = document.body.innerText;
    const get = (label) => {
      const i = text.indexOf(label);
      if (i === -1) return '';
      return text.slice(i + label.length, text.indexOf('\n', i + label.length + 1)).replace(/^\n/, '').split('\n')[0].trim();
    };
    return {
      legal_name: get('الاسم التجاري'),
      cr_number: get('رقم السجل التجاري'),
      category: get('تصنيف العمل'),
      email: get('بريد إلكتروني'),
      phone: get('خدمة عملاء'),
      found: /بيانات التواصل/.test(text),
    };
    });
  } finally {
    await page.close().catch(() => {});
  }
}
}

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
  const browser = await launchBrowser();
  let enriched = 0, emailsFound = 0, phonesFound = 0;
  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});
  }

  const cols = [...Object.keys(rows[0] || {})];
  const csv = [cols.join(',')].concat(rows.map((r) => cols.map((c) => esc(r[c])).join(','))).join('\n') + '\n';
  fs.writeFileSync(OUT_FILE, csv);
  console.log(`\n✅ ${rows.length} rows written to ${OUT_FILE}`);
  console.log(`Enriched: ${enriched} had a maroof_url and loaded · ${emailsFound} emails · ${phonesFound} phones`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });


Now let's update the caller to pass `browser` instead of `page` and remove the now-unused shared page setup:


95	(async () => {
96	  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
97	  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
98	  const browser = await launchBrowser();
99	  let enriched = 0, emailsFound = 0, phonesFound = 0;
100	  try {
101	    const page = await newPage(browser);
102	    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
103	    await new Promise((r) => setTimeout(r, 1500));
104
105	    for (const r of rows) {
106	      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
107	      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
108	      if (!m) continue;
109	      try {
110	        const data = await scrapeListing(page, m[1]);
111	        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
112	        enriched++;
113	        r.maroof_legal_name = data.legal_name;
114	        r.maroof_email = data.email;
115	        r.maroof_phone = data.phone;
116	        if (data.email) emailsFound++;
117	        if (data.phone) phonesFound++;
118	        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
119	      } catch (e) {
120	        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
121	      }
122	      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
123	    }
124	    await page.close().catch(() => {});
125	  } finally {
126	    await browser.close().catch(() => {});

(async () => {
  const rows = parseCsv(fs.readFileSync(IN_FILE, 'utf8'));
  console.log(`Loaded ${rows.length} rows from ${IN_FILE}`);
  const browser = await launchBrowser();
  let enriched = 0, emailsFound = 0, phonesFound = 0;
  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});


  try {
    const page = await newPage(browser);
    await page.goto('https://maroof.sa/', { waitUntil: 'networkidle2', timeout: 45000 });
    await new Promise((r) => setTimeout(r, 1500));

    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(page, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
    await page.close().catch(() => {});
  } finally {
    await browser.close().catch(() => {});

  try {
    for (const r of rows) {
      r.maroof_email = ''; r.maroof_phone = ''; r.maroof_legal_name = '';
      const m = (r.maroof_url || '').match(/maroof\.sa\/(\d+)/);
      if (!m) continue;
      try {
        const data = await scrapeListing(browser, m[1]);
        if (!data.found) { console.log(`[no data] ${r.store_name} — maroof/${m[1]}`); continue; }
        enriched++;
        r.maroof_legal_name = data.legal_name;
        r.maroof_email = data.email;
        r.maroof_phone = data.phone;
        if (data.email) emailsFound++;
        if (data.phone) phonesFound++;
        console.log(`[${enriched}] ${r.store_name} — ${data.email || '(no email)'} — ${data.phone || '(no phone)'}`);
      } catch (e) {
        console.log(`[error] ${r.store_name} — maroof/${m[1]} — ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, 800 + Math.random() * 800));
    }
  } finally {
    await browser.close().catch(() => {});