| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650 |
- """
- LinkedIn Morocco dealer discovery.
- Preview-first workflow:
- - Connect to an already-open AdsPower browser by profile ID.
- - Search LinkedIn company results with Morocco-wide dealer/importer keywords.
- - Score and de-duplicate candidates before opening company pages.
- - Reject OEM local brand-country pages such as BYD Maroc or BMW Maroc.
- - Optionally deep-scrape company About pages and write records to the LinkedIn sheet.
- """
- import argparse
- import json
- import random
- import re
- import sys
- import time
- from datetime import datetime, timezone
- from pathlib import Path
- import sys
- sys.path.append(str(Path(__file__).resolve().parents[1]))
- from common.artifact_manager import resolve_artifact_path, create_backup_once
- from typing import Any, Dict, List, Optional, Set, Tuple
- from urllib.parse import quote_plus, urlparse
- if hasattr(sys.stdout, "reconfigure"):
- sys.stdout.reconfigure(encoding="utf-8")
- if hasattr(sys.stderr, "reconfigure"):
- sys.stderr.reconfigure(encoding="utf-8")
- import pandas as pd
- import requests
- from playwright.sync_api import Page, sync_playwright
- try:
- from ..common import append_records, resolve_workbook_path
- from . import discovery_common as dc
- except ImportError:
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
- from common import append_records, resolve_workbook_path
- from scraper import discovery_common as dc
- DEFAULT_EXCEL = ""
- DEFAULT_SHEET = "LinkedIn"
- DEFAULT_CITY_SCOPE = "摩洛哥全国"
- DEFAULT_KEYWORDS = [
- "showroom auto Maroc",
- "concessionnaire automobile Maroc",
- "concessionnaire multimarque Maroc",
- "distributeur automobile Maroc",
- "importateur automobile Maroc",
- "groupe automobile Maroc",
- "voiture occasion Maroc",
- "concessionnaire utilitaire Maroc",
- "La Continentale Auto Maroc",
- "Prince Auto Maroc",
- "Kifal Auto Maroc",
- "Bamotors Maroc",
- "SMEIA Maroc",
- "Sopriam Maroc",
- "Auto Hall Maroc",
- "Auto Nejma Maroc",
- "Centrale Automobile Chérifienne Maroc",
- ]
- MOROCCO_TERMS = {
- "maroc", "morocco", "casablanca", "rabat", "marrakech", "tanger",
- "fes", "fès", "agadir", "meknes", "oujda", "kenitra", "tetouan",
- "nador", "safi", "settat", "dar bouazza", "el jadida",
- }
- CHINA_BRAND_TERMS = {
- "baic", "byd", "changan", "chery", "dfsk", "dongfeng", "foton", "forland",
- "gac", "geely", "great wall", "haval", "jac", "jetour", "maxus", "mg",
- "omoda", "saic", "sitrak", "sinotruk", "shacman", "faw", "yutong",
- "king long", "golden dragon", "higer", "wuling",
- }
- NON_CHINA_BRAND_TERMS = {
- "audi", "bmw", "chevrolet", "citroen", "citroën", "dacia", "daf", "fiat",
- "ford", "hino", "honda", "hyundai", "isuzu", "iveco", "jeep", "kia", "man",
- "mazda", "mercedes", "mercedes-benz", "mitsubishi", "nissan", "opel", "peugeot",
- "renault", "scania", "seat", "skoda", "suzuki", "toyota", "volkswagen", "volvo",
- }
- OEM_BRANCH_BRANDS = CHINA_BRAND_TERMS | NON_CHINA_BRAND_TERMS
- INDEPENDENT_CHANNEL_CLUES = {
- "auto hall", "smaa", "smeia", "auto nejma", "la continentale", "prince auto",
- "kifal", "autochek", "bugshan", "bamotors", "sopriam", "cac",
- "centrale automobile", "univers motors", "m-automotiv", "m automotiv",
- }
- HIGH_INTENT_TERMS = {
- "concessionnaire", "dealer", "distributeur", "distribution", "importateur",
- "importation", "showroom", "groupe", "group", "automobile", "auto", "retail",
- "vente", "voiture", "occasion", "reprise", "multimarque", "service après-vente",
- "après-vente", "pieces", "pièces", "fleet", "flotte", "utilitaire", "camion",
- }
- LOW_VALUE_TERMS = {
- "aerospace", "aéronautique", "assurance", "insurance", "software", "marketing",
- "emailing", "real estate", "immobilier", "building materials", "construction materials",
- "diagnostic", "spare parts only", "pièces uniquement",
- }
- CITY_PATTERNS = [
- "Casablanca", "Rabat", "Marrakech", "Tanger", "Fes", "Fès", "Agadir", "Meknes",
- "Oujda", "Kenitra", "Tetouan", "Tétouan", "Nador", "Safi", "Settat",
- "Dar Bouazza", "El Jadida",
- ]
- def compact_text(value: str) -> str:
- return re.sub(r"[^a-z0-9]+", "", value.casefold())
- def normalize_linkedin_url(url: str) -> str:
- return dc.normalize_linkedin_url(url)
- def load_existing_links(excel_path: str, sheet_name: str) -> Set[str]:
- path = Path(excel_path)
- if not path.exists():
- return set()
- try:
- df = pd.read_excel(path, sheet_name=sheet_name)
- except Exception:
- return set()
- link_col = "linkin链接" if "linkin链接" in df.columns else "主页/链接"
- if link_col not in df.columns:
- return set()
- return {
- normalized
- for normalized in (normalize_linkedin_url(str(value).strip()) for value in df[link_col].tolist())
- if normalized
- }
- def load_blocklist_json(blocklist_path: Optional[str]) -> Set[str]:
- return dc.load_blocklist_json(blocklist_path, normalizer=dc.normalize_linkedin_url)
- def matched_terms(text: str, terms: Set[str]) -> List[str]:
- text_lower = text.casefold()
- return sorted(term for term in terms if term.casefold() in text_lower)
- def looks_like_oem_local_branch(name: str, url: str, text: str = "") -> Tuple[bool, str]:
- return dc.looks_like_oem_local_branch(name, url, text)
- def score_candidate(candidate: Dict[str, Any], existing_links: Set[str], existing_names: Optional[Set[str]] = None) -> Dict[str, Any]:
- name = candidate.get("name", "")
- href = candidate.get("href", "")
- text = candidate.get("text", "")
- queries = candidate.get("source_queries", [])
- entity_text = " ".join([name, href, text]).casefold()
- query_text = " ".join(queries).casefold()
- combined = " ".join([entity_text, query_text]).casefold()
- score = 0
- reasons: List[str] = []
- risks: List[str] = []
- recommended_action = "preview_only"
- normalized = normalize_linkedin_url(href)
- existing_names = existing_names or set()
- name_key = dc.normalized_company_key(name)
- if normalized in existing_links:
- return {
- **candidate,
- "score": -10,
- "score_reasons": ["Already exists in workbook"],
- "risk_flags": ["duplicate_existing_link"],
- "recommended_action": "skip_existing",
- }
- if name_key and name_key in existing_names:
- return {
- **candidate,
- "score": -9,
- "score_reasons": ["Company name already exists in workbook"],
- "risk_flags": ["duplicate_existing_name"],
- "recommended_action": "skip_existing",
- }
- is_oem, brand = looks_like_oem_local_branch(name, href, text)
- brand_terms = matched_terms(entity_text, OEM_BRANCH_BRANDS)
- if is_oem:
- score -= 8
- risks.append(f"疑似品牌当地分公司/官方页,排除: {brand}")
- recommended_action = "skip_brand_branch"
- ownership_needs_review = (not is_oem) and brand_terms and not any(clue in entity_text for clue in INDEPENDENT_CHANNEL_CLUES)
- morocco_terms = matched_terms(entity_text, MOROCCO_TERMS)
- if morocco_terms:
- score += 2
- reasons.append("Morocco signal: " + ", ".join(morocco_terms[:4]))
- intent_terms = matched_terms(entity_text, HIGH_INTENT_TERMS)
- if intent_terms:
- score += min(5, len(intent_terms))
- reasons.append("dealer/import/channel signal: " + ", ".join(intent_terms[:6]))
- china_terms = matched_terms(entity_text, CHINA_BRAND_TERMS)
- if china_terms and not is_oem:
- score += 2
- reasons.append("China-brand context inside dealer channel: " + ", ".join(china_terms[:4]))
- canonical_name = dc.canonical_dealer_name(name, text)
- if canonical_name and canonical_name != name:
- candidate = {**candidate, "canonical_name": canonical_name}
- reasons.append(f"mapped to independent dealer group: {canonical_name}")
- if any(clue in entity_text for clue in INDEPENDENT_CHANNEL_CLUES):
- score += 4
- reasons.append("known independent Moroccan auto channel")
- low_terms = matched_terms(entity_text, LOW_VALUE_TERMS)
- if low_terms:
- score -= min(4, len(low_terms) * 2)
- risks.append("possible low-value/non-dealer result: " + ", ".join(low_terms[:4]))
- channel_terms = matched_terms(entity_text, {"concessionnaire", "dealer", "showroom", "new vehicle", "new cars", "vehicules neufs", "v?hicules neufs", "vente", "importateur", "distributeur", "distribution", "groupe", "group", "multimarque", "multi-brand"})
- new_vehicle_needs_review = not channel_terms
- if ownership_needs_review and dc.should_request_manual_review(score, recommended_action, reasons):
- dc.add_manual_review_flag(risks, "ownership")
- if new_vehicle_needs_review and dc.should_request_manual_review(score, recommended_action, reasons):
- dc.add_manual_review_flag(risks, "new_vehicle")
- if not reasons:
- reasons.append("weak LinkedIn search signal; low priority unless deeper evidence confirms channel value")
- if recommended_action == "preview_only" and score >= 5:
- recommended_action = "deep_scrape"
- return {
- **candidate,
- "score": score,
- "score_reasons": reasons,
- "risk_flags": risks,
- "recommended_action": recommended_action,
- }
- def get_active_ws_endpoint(base_url: str, profile_id: str) -> str:
- return dc.get_active_ws_endpoint(base_url, profile_id)
- def dismiss_linkedin_dialogs(page: Page) -> None:
- labels = [
- "继续前往公司主页",
- "Continue to company page",
- "Continuer vers la page de l’entreprise",
- "关闭",
- "Close",
- ]
- for label in labels:
- try:
- page.get_by_text(label, exact=False).first.click(timeout=1500)
- time.sleep(0.5)
- except Exception:
- pass
- def collect_company_results(page: Page, query: str, max_results: int) -> List[Dict[str, Any]]:
- url = f"https://www.linkedin.com/search/results/companies/?keywords={quote_plus(query)}&origin=GLOBAL_SEARCH_HEADER"
- print(f"搜索 LinkedIn: {query}", flush=True)
- page.goto(url, wait_until="domcontentloaded", timeout=60000)
- time.sleep(random.uniform(3, 5))
- dismiss_linkedin_dialogs(page)
- for _ in range(2):
- page.mouse.wheel(0, 900)
- time.sleep(random.uniform(1, 1.8))
- raw = page.evaluate(
- """
- () => {
- const anchors = Array.from(document.querySelectorAll('a[href*="/company/"]'));
- return anchors.map((a) => {
- let node = a.closest('li') || a.closest('.reusable-search__result-container') || a.parentElement;
- let text = '';
- let cur = node;
- for (let i = 0; i < 7 && cur; i++) {
- const value = (cur.innerText || '').trim();
- if (value.length > text.length) text = value;
- if (value.length > 80) break;
- cur = cur.parentElement;
- }
- return { href: a.href || '', anchorText: (a.innerText || '').trim(), text };
- });
- }
- """
- )
- results: List[Dict[str, Any]] = []
- seen: Set[str] = set()
- for item in raw:
- href = normalize_linkedin_url(item.get("href", ""))
- if not href or href in seen:
- continue
- text = re.sub(r"\n{2,}", "\n", item.get("text", "")).strip()
- lines = [line.strip() for line in text.splitlines() if line.strip()]
- anchor = re.sub(r"\s+", " ", item.get("anchorText", "")).strip()
- if lines:
- name = lines[0]
- elif anchor and len(anchor) <= 80:
- name = anchor
- else:
- name = anchor.split(" ")[0].strip()
- name = re.sub(r"\s+\d+(?:\.\d+)?\s*万?\s*位关注者.*$", "", name).strip()
- if not name or name.lower() in {"linkedin", "home", "search"}:
- continue
- seen.add(href)
- results.append({
- "name": name,
- "href": href,
- "text": text[:1000],
- "source_queries": [query],
- })
- if len(results) >= max_results:
- break
- print(f" 收集到 {len(results)} 个公司候选", flush=True)
- return results
- def merge_candidates(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
- by_href: Dict[str, Dict[str, Any]] = {}
- for item in items:
- href = item["href"]
- if href not in by_href:
- by_href[href] = item
- continue
- existing = by_href[href]
- existing["source_queries"] = sorted(set(existing.get("source_queries", []) + item.get("source_queries", [])))
- snippets = existing.setdefault("snippets", [existing.get("text", "")])
- if item.get("text") and item["text"] not in snippets:
- snippets.append(item["text"])
- return list(by_href.values())
- def extract_lines(text: str) -> List[str]:
- return [line.strip() for line in text.splitlines() if line.strip()]
- def value_after_label(lines: List[str], label: str) -> str:
- for idx, line in enumerate(lines):
- if line.strip() == label and idx + 1 < len(lines):
- return lines[idx + 1].strip()
- return ""
- def extract_urls(text: str) -> List[str]:
- urls = re.findall(r"https?://[^\s<>()]+", text)
- cleaned: List[str] = []
- for url in urls:
- url = url.rstrip(".,,。;;")
- if "linkedin.com" not in url and url not in cleaned:
- cleaned.append(url)
- return cleaned
- def extract_email(text: str) -> str:
- match = re.search(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", text)
- return match.group(0) if match else ""
- def extract_phone(text: str) -> str:
- explicit = re.search(r"(?:Téléphone|电话|Tél|Tel|WhatsApp|电话号码)[::]?\s*([+()\d][+()\d\s.-]{6,}\d)", text, re.I)
- if explicit:
- return re.sub(r"\s+", " ", explicit.group(1)).strip()
- match = re.search(r"(?:\+212|0)\s?\d[\d\s.-]{6,}\d", text)
- return re.sub(r"\s+", " ", match.group(0)).strip() if match else ""
- def extract_city(text: str, fallback: str = DEFAULT_CITY_SCOPE) -> str:
- hits = []
- for city in CITY_PATTERNS:
- if re.search(rf"\b{re.escape(city)}\b", text, re.I) and city not in hits:
- hits.append(city)
- return " / ".join(hits[:4]) if hits else fallback
- def extract_followers(text: str) -> str:
- match = re.search(r"([\d,.]+\s*万?|[\d,.]+)\s*位关注者", text)
- return match.group(1).strip() + "位关注者" if match else ""
- def classify_customer_type(text: str) -> str:
- return dc.classify_customer_type(str(text or ""))
- def summarize_business(text: str) -> str:
- lower = text.casefold()
- parts: List[str] = []
- if any(term in lower for term in ["occasion", "reprise", "voiture d'occasion"]):
- parts.append("二手车买卖/置换")
- if any(term in lower for term in ["concessionnaire", "showroom", "vente automobile", "汽车零售"]):
- parts.append("汽车销售/showroom")
- if any(term in lower for term in ["importateur", "distribution", "distributeur"]):
- parts.append("进口/分销")
- if any(term in lower for term in ["utilitaire", "camion", "truck", "fleet", "flotte"]):
- parts.append("商用车/车队")
- china = matched_terms(text, CHINA_BRAND_TERMS)
- non_china = matched_terms(text, NON_CHINA_BRAND_TERMS)
- if china:
- parts.append("涉及中国品牌: " + ", ".join(china[:4]))
- elif non_china:
- parts.append("主要品牌信号: " + ", ".join(non_china[:5]))
- return ";".join(parts) if parts else "LinkedIn 汽车渠道线索,主营业务证据不足,需深搜确认"
- def deep_scrape_about(page: Page, candidate: Dict[str, Any], country: str) -> Dict[str, Any]:
- about_url = candidate["href"].rstrip("/") + "/about/"
- print(f"深采 LinkedIn About: {candidate['name']}", flush=True)
- page.goto(about_url, wait_until="domcontentloaded", timeout=60000)
- time.sleep(random.uniform(3, 5))
- dismiss_linkedin_dialogs(page)
- time.sleep(random.uniform(1, 2))
- title = page.title()
- text = page.locator("body").inner_text(timeout=10000)
- if not text.strip():
- candidate.setdefault("risk_flags", [])
- dc.add_manual_review_flag(candidate["risk_flags"], "detail")
- lines = extract_lines(text)
- urls = extract_urls(text)
- website = value_after_label(lines, "网站") or (urls[0] if urls else "")
- phone = value_after_label(lines, "电话") or extract_phone(text)
- email = extract_email(text)
- industry = value_after_label(lines, "行业")
- city = extract_city(text)
- followers = extract_followers(text)
- company_type = classify_customer_type(text + "\n" + candidate.get("text", ""))
- business = summarize_business(text + "\n" + candidate.get("text", ""))
- is_oem, brand = looks_like_oem_local_branch(candidate.get("name", ""), candidate.get("href", ""), text)
- if is_oem:
- candidate["recommended_action"] = "skip_brand_branch"
- candidate.setdefault("risk_flags", []).append(f"深采确认疑似品牌官方页: {brand}")
- dc.add_manual_review_flag(candidate["risk_flags"], "ownership")
- note_parts = [
- "LinkedIn深采",
- f"行业:{industry}" if industry else "",
- f"关注者:{followers}" if followers else "",
- f"网站:{website}" if website else "",
- f"来源搜索词:{', '.join(candidate.get('source_queries', []))}",
- f"评分:{candidate.get('score')};原因:{'; '.join(candidate.get('score_reasons', []))}",
- ]
- if candidate.get("risk_flags"):
- note_parts.append("风险:" + "; ".join(candidate["risk_flags"]))
- note = " | ".join(part for part in note_parts if part)
- return {
- "公司名称": candidate.get("canonical_name") or candidate.get("name", ""),
- "国家": country,
- "城市": city,
- "客户类型": company_type,
- "linkin链接": candidate.get("href", ""),
- "联系人": "",
- "职位": "",
- "公司公共电话": phone,
- "公司公共邮箱(任一有效即可)": email,
- "个人邮箱": "",
- "公司主营业务": business,
- "建联状态": "未联系",
- "备注": note[:1200],
- "_debug_title": title,
- "_about_head": lines[:60],
- }
- def search_linkedin_dealers(
- profile_id: str,
- keywords: Optional[List[str]],
- ads_power_url: str,
- excel_path: str,
- sheet_name: str,
- max_results: int,
- max_results_per_query: int,
- min_score: int,
- deep_scrape: bool,
- country: str,
- blocklist_json: Optional[str] = None,
- ) -> Dict[str, Any]:
- existing_identity = dc.load_existing_identity(
- excel_path=excel_path,
- sheet_name=sheet_name,
- link_columns=["linkin链接", "主页/链接"],
- name_columns=["公司名称", "客户姓名/公司"],
- )
- existing_links = existing_identity["links"]
- existing_names = existing_identity["names"]
- blocklist_links = load_blocklist_json(blocklist_json)
- existing_links = existing_links | blocklist_links
- print(f"已从 {sheet_name} Sheet 加载 {len(existing_links)} 个 LinkedIn 现有链接、{len(existing_names)} 个公司名用于去重", flush=True)
- if blocklist_json:
- print(f" 其中来自黑名单 JSON: {len(blocklist_links)} 个", flush=True)
- ws_endpoint = get_active_ws_endpoint(ads_power_url, profile_id)
- playwright = sync_playwright().start()
- browser = playwright.chromium.connect_over_cdp(ws_endpoint)
- context = browser.contexts[0] if browser.contexts else browser.new_context()
- page = context.new_page()
- page.set_viewport_size({"width": 1366, "height": 850})
- queries = keywords or DEFAULT_KEYWORDS
- raw_candidates: List[Dict[str, Any]] = []
- search_log: List[Dict[str, Any]] = []
- records: List[Dict[str, Any]] = []
- try:
- for query in queries:
- items = collect_company_results(page, query, max_results_per_query)
- raw_candidates.extend(items)
- search_log.append({"query": query, "found": len(items), "url": page.url, "title": page.title()})
- time.sleep(random.uniform(2, 4))
- merged = merge_candidates(raw_candidates)
- scored_all = [score_candidate(item, existing_links, existing_names) for item in merged]
- skipped_existing = [c for c in scored_all if c.get("recommended_action") == "skip_existing"]
- active_scored = [c for c in scored_all if c.get("recommended_action") != "skip_existing"]
- scored = dc.dedupe_dealer_groups(active_scored)
- scored.sort(key=lambda c: (-int(c.get("score", 0)), c.get("name", "")))
- selected = [
- c for c in scored
- if c.get("recommended_action") == "deep_scrape" and int(c.get("score", 0)) >= min_score
- ][:max_results]
- if deep_scrape:
- for candidate in selected:
- record = deep_scrape_about(page, candidate, country=country)
- if candidate.get("recommended_action") == "skip_brand_branch":
- continue
- records.append(record)
- time.sleep(random.uniform(2, 4))
- finally:
- try:
- page.close()
- except Exception:
- pass
- playwright.stop()
- return {
- "generated_at": datetime.now(timezone.utc).isoformat(),
- "source": "LinkedIn company search via AdsPower active profile",
- "summary": {
- "profile_id": profile_id,
- "queries": queries,
- "raw_candidates": len(raw_candidates),
- "candidate_count": len(scored),
- "selected_for_deep_scrape": len(selected),
- "deep_scrape": deep_scrape,
- "record_count": len(records),
- "min_score": min_score,
- "max_results": max_results,
- "existing_links": len(existing_links),
- "existing_names": len(existing_names),
- "skipped_existing": len(skipped_existing),
- },
- "search_log": search_log,
- "candidates": scored,
- "skipped_existing": skipped_existing,
- "selected_candidates": selected,
- "records": records,
- }
- def parse_keywords(value: str) -> Optional[List[str]]:
- if not value.strip():
- return None
- return [item.strip() for item in value.split(",") if item.strip()]
- def main() -> None:
- parser = argparse.ArgumentParser(description="LinkedIn Morocco dealer discovery, preview-first")
- parser.add_argument("--profile-id", required=True, help="AdsPower profile ID; must already be open and logged in")
- parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower local API URL")
- parser.add_argument("--keywords", default="", help="Comma-separated LinkedIn company search keywords")
- parser.add_argument("--excel", default=DEFAULT_EXCEL, help="Workbook for duplicate checking and optional write-back")
- parser.add_argument("--sheet", default=DEFAULT_SHEET, help="Target sheet, normally LinkedIn")
- parser.add_argument("--country", default="摩洛哥", help="Country value for records")
- parser.add_argument("--max-results", type=int, default=10, help="Maximum deep-scraped records")
- parser.add_argument("--max-results-per-query", type=int, default=5, help="Company links collected per query")
- parser.add_argument("--min-score", type=int, default=5, help="Minimum score for deep-scrape selection")
- parser.add_argument("--deep-scrape", action="store_true", help="Open selected company About pages and build records")
- parser.add_argument("--write-excel", action="store_true", help="Write deep-scraped records to the workbook")
- parser.add_argument("--blocklist-json", default="", help="Optional JSON file with additional LinkedIn URLs to skip")
- parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
- parser.add_argument("--output", default="linkedin_candidate_preview.json", help="Output JSON path")
- args = parser.parse_args()
- read_workbook_info = resolve_workbook_path(args.excel, create_from_template=False)
- if read_workbook_info.get("path"):
- args.excel = str(read_workbook_info["path"])
- print(f"Workbook for duplicate checking: {args.excel} ({read_workbook_info['source']})", flush=True)
- result = search_linkedin_dealers(
- profile_id=args.profile_id,
- keywords=parse_keywords(args.keywords),
- ads_power_url=args.ads_power_url,
- excel_path=args.excel,
- sheet_name=args.sheet,
- max_results=args.max_results,
- max_results_per_query=args.max_results_per_query,
- min_score=args.min_score,
- deep_scrape=args.deep_scrape,
- country=args.country,
- blocklist_json=args.blocklist_json or None,
- )
- output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
- output_path.parent.mkdir(parents=True, exist_ok=True)
- output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
- print(f"已保存 LinkedIn 候选结果: {output_path}", flush=True)
- if args.write_excel:
- write_workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
- if write_workbook_info.get("path"):
- args.excel = str(write_workbook_info["path"])
- print(f"Workbook for writing: {args.excel} ({write_workbook_info['source']})", flush=True)
- if not args.deep_scrape:
- print("未写入 Excel:需要先使用 --deep-scrape 生成 records。", flush=True)
- elif not result.get("records"):
- print("未写入 Excel:没有可写入记录。", flush=True)
- else:
- write_result = append_records(
- excel_path=args.excel,
- sheet_name=args.sheet,
- records=result["records"],
- dedup_keys=["公司名称", "linkin链接"],
- )
- print(f"Excel 回写结果: {write_result}", flush=True)
- else:
- print("默认预览模式:未写入 Excel。确认要入表时再使用 --deep-scrape --write-excel。", flush=True)
- if __name__ == "__main__":
- main()
|