Yellow Pages Malaysia
Pricing
from $0.05 / actor start
Yellow Pages Malaysia
Extract high-quality B2B leads effortlessly. This Yellow Pages Scraper automates data collection from business listings, capturing essential details including contact info, locations, ratings, and URLs. Perfect for lead generation, market research, and sales outreach.
Pricing
from $0.05 / actor start
Rating
0.0
(0)
Developer
Jai
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
a month ago
Last modified
Categories
Share
"""YellowPages.my Business Scraper — Apify Actor."""
import asyncio import html import json import random import re from urllib.parse import urljoin, urlparse
import httpx from playwright_stealth import Stealth
from apify import Actor from crawlee import ConcurrencySettings, Request from crawlee.crawlers import PlaywrightCrawler, PlaywrightCrawlingContext from crawlee.proxy_configuration import ProxyConfiguration
BASE_URL = "https://www.yellowpages.my" API_URL = "https://api.yellowpages.my"
TRANSFERSTATE_DUMPED = False
async def discover_categories() -> list[dict]: """Fetch categories from the unprotected /home API endpoint.""" categories = [] async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: resp = await client.get( f"{API_URL}/home", headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, ) if resp.status_code == 200: data = resp.json().get("data", {}) # API uses 'categories' key with nested structure for cat in data.get("categories", []): slug = cat.get("slug", "") name = cat.get("name", "") if slug: categories.append({ "name": name, "slug": slug, "url": f"{BASE_URL}/category/{slug}", "count": cat.get("post_count", 0), }) Actor.log.info(f"Discovered {len(categories)} categories from /home API") else: Actor.log.warning(f"Failed to fetch /home API: {resp.status_code}")
return categories
def _decode_transferstate(raw: str) -> dict: """Decode TransferState content with &q; encoding and HTML entities.""" # Replace &q; with double quotes, &a; with & decoded = raw.replace("&q;", '"').replace("&a;", "&") # Unescape HTML entities like … etc. decoded = html.unescape(decoded) return json.loads(decoded)
def _deep_find_profile(data: dict) -> dict | None: """Find profile data in TransferState dict.
Looks for key matching pattern like 'profile-data' or similar,then returns body.data from that entry."""for key, value in data.items():if isinstance(value, dict) and "body" in value:body = value["body"]if isinstance(body, dict) and "data" in body:profile = body["data"]if isinstance(profile, dict) and "title" in profile:return profilereturn None
def extract_from_transfer_state(data: dict, url: str) -> dict: """Parse Angular TransferState JSON for business data.""" record = { "url": url, "name": None, "store_name": None, "phone": None, "phone_masked": None, "extra_phones": [], "fax_number": None, "address": None, "categories": [], "website_url": None, "hours": None, "structured_hours": [], "description": None, "email": None, "lat": None, "lon": None, "rating": None, "rating_count": None, "photo": None, "all_images": [], "company_no": None, "plan": None, "is_claimed": None, "social_media": {}, "view_count": None, }
try:profile = _deep_find_profile(data)if profile:record["name"] = profile.get("title")record["view_count"] = profile.get("view_count")locations = profile.get("locations", [])profile_nested = profile.get("profile", {}) if isinstance(profile.get("profile"), dict) else {}# Phone: masked from first locationfor loc in locations:phone = loc.get("company_phone_number")if phone:record["phone_masked"] = phonebreak# Fax number from first locationif locations:fax = locations[0].get("fax_number")if fax:record["fax_number"] = fax# Extra phones from additional locationsfor loc in locations[1:]:extra_phone = loc.get("company_phone_number")if extra_phone:record["extra_phones"].append(extra_phone)# Email from first locationif locations:email = locations[0].get("email")if email:record["email"] = email# Address: combine from first locationif locations:loc = locations[0]addr_parts = [loc.get("address_line1"),loc.get("address_line2"),f"{loc.get('postcode', '')} {loc.get('city_state', '')}".strip()]record["address"] = ", ".join(p for p in addr_parts if p) or None# Categories: all categories as arrayfor cat in profile.get("categories", []):if isinstance(cat, dict):cat_name = cat.get("name")if cat_name:parent = cat.get("parent_category")if parent and isinstance(parent, dict):parent_name = parent.get("name")if parent_name:cat_name = f"{parent_name} > {cat_name}"record["categories"].append(cat_name)elif isinstance(cat, str):record["categories"].append(cat)# Coordinates: skip if "0"if locations:loc = locations[0]lat = loc.get("latitude")lon = loc.get("longitude")if lat and lat != "0":try:record["lat"] = float(lat)except (ValueError, TypeError):passif lon and lon != "0":try:record["lon"] = float(lon)except (ValueError, TypeError):pass# Description: prefer profile.profile.about over profile.summaryabout_text = profile_nested.get("about") if profile_nested else Nonesummary = profile.get("summary")if about_text:record["description"] = html.unescape(about_text)elif summary:record["description"] = html.unescape(summary)# Extract unmasked phone from about textsource_text = about_text or ""phone_patterns = [r'(?:Telephone|Tel|Phone|Call)\s*[:\-]?\s*(?:\(?\+?60\)?[\s\-]?)?([\d][\d\s\-]{8,14}\d)',r'(?:\+?60[\s\-])?(\d{2,3}[\s\-]\d{3,4}[\s\-]\d{4})',r'(\d{3}[\s\-]\d{4}[\s\-]\d{4})',]for pat in phone_patterns:match = re.search(pat, source_text, re.IGNORECASE)if match:raw_phone = match.group(0).strip()phone_match = re.search(r'(\+?60[\s\-]?\d[\d\s\-]{7,12}\d)', raw_phone)if phone_match:record["phone"] = phone_match.group(1).strip()else:record["phone"] = re.search(r'([\d][\d\s\-]{7,12}\d)', raw_phone).group(1).strip()break# Extract operating hours from about text (fallback)hours_patterns = [r'(?:Opening\s*Hours?|Business\s*Hours?|Operating\s*Hours?|Office\s*Hours?)\s*[:\-]?\s*(.+?)(?:\n\n|\n[A-Z]|$)',r'((?:Monday|Mon)[\s:].+?(?:\n\n|\n[A-Z]|$))',]for pat in hours_patterns:match = re.search(pat, source_text, re.IGNORECASE | re.DOTALL)if match:hours = match.group(1) if match.lastindex else match.group(0)hours = hours.strip()[:200]hours = re.split(r'\n(?:Licence|MEMBERSHIP|ASSOCIATES|Product)', hours)[0].strip()if hours:record["hours"] = hoursbreak# Fallback: masked phone from locations if no unmasked foundif not record["phone"] and record["phone_masked"]:record["phone"] = record["phone_masked"]# Structured hours from profile.profile.business_hoursbusiness_hours = profile_nested.get("business_hours", [])if isinstance(business_hours, list):for entry in business_hours:if isinstance(entry, dict):record["structured_hours"].append({"day": entry.get("day"),"open": entry.get("open"),"close": entry.get("close"),})# Rating: from profile.profile.ratingrating = profile_nested.get("rating")if rating:try:record["rating"] = float(rating)except (ValueError, TypeError):passrating_count = profile_nested.get("rating_count")if rating_count:try:record["rating_count"] = int(rating_count)except (ValueError, TypeError):pass# Photo: from profile.profile.image.thumbnailimage = profile_nested.get("image", {})if isinstance(image, dict):thumbnail = image.get("thumbnail")if thumbnail:if thumbnail.startswith("/"):record["photo"] = f"{BASE_URL}{thumbnail}"elif not thumbnail.startswith("http"):record["photo"] = f"{BASE_URL}/{thumbnail}"else:record["photo"] = thumbnail# All images from profile.profile.images[]for img in profile_nested.get("images", []):if isinstance(img, dict):img_data = {}full = img.get("full") or img.get("image", {}).get("full") if isinstance(img.get("image"), dict) else img.get("full")thumb = img.get("thumbnail") or img.get("image", {}).get("thumbnail") if isinstance(img.get("image"), dict) else img.get("thumbnail")if full:img_data["full"] = f"{BASE_URL}{full}" if full.startswith("/") else fullif thumb:img_data["thumbnail"] = f"{BASE_URL}{thumb}" if thumb.startswith("/") else thumbif img_data:record["all_images"].append(img_data)# Website URLurl_val = profile_nested.get("url")if url_val:record["website_url"] = url_val# Company number (SSM)company_no = profile_nested.get("company_no")if company_no:record["company_no"] = company_no# Business plan/tierplan = profile_nested.get("plan")if plan:record["plan"] = plan# Store name (alternate business name)store_name = profile_nested.get("store_name")if store_name:record["store_name"] = store_name# Claimed statusclaimed = profile_nested.get("claimed")if claimed is not None:record["is_claimed"] = int(claimed) if claimed else 0# Social media linkssocial_fields = ["facebook", "instagram", "twitter", "linkedin", "youtube", "whatsapp", "wechat"]for field in social_fields:val = profile_nested.get(field)if val:record["social_media"][field] = valexcept Exception as e:Actor.log.warning(f"TransferState parse error: {e}")return record
async def extract_from_dom(page, url: str) -> dict: """Fallback: extract data from visible DOM elements.""" record = { "url": url, "name": None, "phone": None, "address": None, "category": None, "website": None, "hours": None, "description": None, "lat": None, "lon": None, "rating": None, "photo": None, }
try:record["name"] = await page.text_content("h1") or Nonephone_el = await page.query_selector('a[href^="tel:"]')if phone_el:href = await phone_el.get_attribute("href")record["phone"] = href.replace("tel:", "") if href else Noneaddr_el = await page.query_selector('[class*="address"], .listing-address, address')if addr_el:record["address"] = (await addr_el.text_content()).strip()desc_el = await page.query_selector('[class*="description"], .listing-desc, .summary')if desc_el:record["description"] = (await desc_el.text_content()).strip()rating_el = await page.query_selector('[class*="rating"], .star-rating')if rating_el:rating_text = await rating_el.text_content()match = re.search(r"(\d+\.?\d*)", rating_text or "")if match:record["rating"] = float(match.group(1))breadcrumbs = await page.query_selector_all('.breadcrumb a, nav[aria-label="breadcrumb"] a')if breadcrumbs:cats = [await b.text_content() for b in breadcrumbs]cats = [c.strip() for c in cats if c and c.strip() and c.strip() != "Home"]record["category"] = " > ".join(cats) if cats else Nonemap_link = await page.query_selector('a[href*="maps.google"], a[href*="google.com/maps"]')if map_link:href = await map_link.get_attribute("href")match = re.search(r"@(-?\d+\.?\d*),(-?\d+\.?\d*)", href or "")if match:record["lat"] = float(match.group(1))record["lon"] = float(match.group(2))except Exception as e:Actor.log.warning(f"DOM extraction error on {url}: {e}")return record
async def _detect_cloudflare(page) -> bool: """Return True if page is a Cloudflare challenge.""" try: title = (await page.title()).lower() if "just a moment" in title or "attention required" in title: return True cf_el = await page.query_selector("#challenge-running, #challenge-stage, .cf-browser-verification") if cf_el: return True body_text = (await page.text_content("body") or "").lower() if "checking your browser" in body_text and "cloudflare" in body_text: return True except Exception: pass return False
async def main() -> None: async with Actor: Actor.log.info("Starting YellowPages.my scraper") actor_input = await Actor.get_input() or {}
input_categories = actor_input.get("categories", [])max_pages = actor_input.get("maxPagesPerCategory", 0)max_concurrency = actor_input.get("maxConcurrency", 5)proxy_input = actor_input.get("proxy")# Phase 1: Discover categoriesif input_categories:categories = [{"slug": c, "url": f"{BASE_URL}/category/{c}"}for c in input_categories]Actor.log.info(f"Using {len(categories)} user-specified categories")else:categories = await discover_categories()Actor.log.info(f"Auto-discovered {len(categories)} categories")if not categories:Actor.log.error("No categories found. Aborting.")returnawait Actor.set_value("categories", categories)Actor.log.info(f"Ready to scrape {len(categories)} categories")stats = {"categories_crawled": 0,"listings_found": 0,"listings_scraped": 0,"errors": 0,"cf_blocked": 0,}category_page_counts: dict[str, int] = {}def _get_category_key(url: str) -> str:"""Extract base category slug from URL for pagination tracking."""path = urlparse(url).path.rstrip("/")parts = path.split("/")# /category/{slug} or /category/{parent}/{sub}if "category" in parts:idx = parts.index("category")return "/".join(parts[idx : idx + 2]) if idx + 1 < len(parts) else pathreturn pathasync def handle_listing(context: PlaywrightCrawlingContext) -> None:page = context.pagerequest = context.requestawait stealth.apply_stealth_async(page)await asyncio.sleep(random.uniform(0.5, 2.0))await page.wait_for_load_state("networkidle", timeout=30000)if await _detect_cloudflare(page):stats["cf_blocked"] += 1Actor.log.warning(f"CF challenge on listing {request.url}, will retry")raise RuntimeError("Cloudflare challenge detected")# Strategy 1: Extract Angular TransferState JSON# TransferState uses &q; encoding, need to extract from HTML directlytransfer_data = Nonetry:# Get full HTML and extract script content via string matchinghtml_content = await page.content()# Find script tag with id="serverApp-state"match = re.search(r'<script[^>]*id="serverApp-state"[^>]*>(.*?)</script>',html_content,re.DOTALL)if match:raw_content = match.group(1)# Decode &q; to " and unescape HTML entitiesdecoded = raw_content.replace("&q;", '"').replace("&a;", "&")decoded = html.unescape(decoded)transfer_data = json.loads(decoded)global TRANSFERSTATE_DUMPEDif not TRANSFERSTATE_DUMPED:await Actor.set_value("transferstate_debug", transfer_data)except Exception as e:Actor.log.warning(f"TransferState extraction failed: {e}")if transfer_data:record = extract_from_transfer_state(transfer_data, request.url)else:record = await extract_from_dom(page, request.url)if record and record.get("name"):await Actor.push_data(record)stats["listings_scraped"] += 1Actor.log.info(f"Scraped: {record['name']}")else:stats["errors"] += 1Actor.log.warning(f"No data extracted from {request.url}")async def handle_category(context: PlaywrightCrawlingContext) -> None:page = context.pagerequest = context.requestawait stealth.apply_stealth_async(page)await asyncio.sleep(random.uniform(0.5, 2.0))await page.wait_for_load_state("networkidle", timeout=30000)if await _detect_cloudflare(page):stats["cf_blocked"] += 1Actor.log.warning(f"CF challenge on category {request.url}, will retry")raise RuntimeError("Cloudflare challenge detected")# Extract listing links (/profile/...)listing_urls = await page.eval_on_selector_all('a[href*="/profile/"]',"elements => elements.map(el => el.href)",)seen: set[str] = set()for url in listing_urls:if url not in seen and "/profile/" in url:seen.add(url)await context.add_requests([Request.from_url(url, label="listing")])stats["listings_found"] += len(seen)stats["categories_crawled"] += 1Actor.log.info(f"Category page {request.url}: found {len(seen)} listing URLs")# Pagination — respect maxPagesPerCategorycategory_key = _get_category_key(request.url)category_page_counts[category_key] = (category_page_counts.get(category_key, 0) + 1)if max_pages > 0 and category_page_counts[category_key] >= max_pages:Actor.log.info(f"Reached max pages ({max_pages}) for {category_key}")returnnext_page = await page.query_selector('a[rel="next"], .pagination .next a, a:has-text("Next")')if next_page:next_url = await next_page.get_attribute("href")if next_url:absolute_url = urljoin(request.url, next_url)await context.add_requests([Request.from_url(absolute_url, label="category")])# Proxy configurationproxy_configuration = Noneif proxy_input:proxy_configuration = ProxyConfiguration()# Phase 2+3+4: Crawl categories and extract listingsstealth = Stealth()crawler = PlaywrightCrawler(max_requests_per_crawl=None,concurrency_settings=ConcurrencySettings(max_concurrency=max_concurrency),headless=True,request_handler_timeout=timedelta(seconds=120),max_request_retries=3,proxy_configuration=proxy_configuration,)@crawler.router.default_handlerasync def default_handler(context: PlaywrightCrawlingContext) -> None:label = context.request.labelif label == "listing":await handle_listing(context)else:await handle_category(context)seed_requests = [Request.from_url(cat["url"], label="category") for cat in categories]await crawler.run(seed_requests)total_attempts = stats["listings_scraped"] + stats["errors"]error_rate = stats["errors"] / total_attempts if total_attempts > 0 else 0if error_rate > 0.5 and total_attempts > 10:Actor.log.error(f"High error rate: {error_rate:.1%} ({stats['errors']}/{total_attempts}). "f"CF blocks: {stats['cf_blocked']}")await Actor.set_value("run_stats", stats)Actor.log.info(f"Run complete: {stats}")
