#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import os
import time
import uuid as _uuid
from typing import Any, Dict, Optional, Callable
import socket
import random
import sqlite3
from urllib.parse import quote
from urllib.request import Request, urlopen, build_opener, ProxyHandler, HTTPSHandler
from urllib.error import HTTPError, URLError
import ssl


BASE_URL = "https://cupid.51job.com"
SIGN_KEY = os.getenv(
    "JOB_SIGN_KEY",
    "abfc8f9dcf8c3f3d8aa294ac5f2cf2cc7767e5592590f39c3f503271dd68562b",
)
FROM_DOMAIN = "51job_weixin_wxapp"

API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9999")
COMPANY_INFO_CACHE: Dict[str, Any] = {}
SUCCESS_LOG_PATH = os.path.join(os.path.dirname(__file__), "success.txt")
_SUCCESS_WRITTEN: set = set()


def _timestamp() -> int:
    """Get current UNIX timestamp.

    Returns:
        int: Current timestamp in seconds.
    """

    return int(time.time())


def _encode_query(params: Optional[Dict[str, object]]) -> str:
    """Encode query parameters preserving insertion order.

    Args:
        params (Optional[Dict[str, object]]): Query parameters to encode.

    Returns:
        str: URL-encoded query string starting with '&' when params exist.
    """

    if not params:
        return ""
    pieces = []
    for k, v in params.items():
        key = quote(str(k), safe="")
        if isinstance(v, (list, tuple)):
            for item in v:
                pieces.append(f"{key}={quote(str(item), safe='')}")
        elif v is None:
            pieces.append(f"{key}=")
        else:
            pieces.append(f"{key}={quote(str(v), safe='')}")
    return "&" + "&".join(pieces)


def build_signature(
    method: str,
    path: str,
    query_params: Optional[Dict[str, object]] = None,
    body_json: Optional[str] = None,
    timestamp: Optional[int] = None,
) -> Dict[str, Any]:
    """Construct signing string and compute HMAC-SHA256 signature.

    Args:
        method (str): HTTP method (GET or POST).
        path (str): API path without leading slash.
        query_params (Optional[Dict[str, object]]): Query parameters for GET.
        body_json (Optional[str]): JSON body string for POST.
        timestamp (Optional[int]): Provided timestamp; generates if None.

    Returns:
        Dict[str, Any]: dict with fields 'sig'(hex), 'signed_path', and 'ts'.
    """

    import hmac
    import hashlib

    ts = timestamp or _timestamp()
    base = f"/{path}?api_key=51job&timestamp={ts}"
    sign_str = base
    method_u = method.upper()
    if method_u == "GET":
        q = _encode_query(query_params)
        sign_str += q
        signed_path = base + q
    else:
        if body_json:
            sign_str += body_json
        signed_path = base
    key_bytes = SIGN_KEY.encode("utf-8")
    sig = hmac.new(key_bytes, sign_str.encode("utf-8"), hashlib.sha256).hexdigest()
    return {"sig": sig, "signed_path": signed_path, "ts": ts}


def _build_headers(
    sign: str,
    content_type: str,
    uuid: Optional[str] = None,
    account_id: Optional[str] = None,
    user_token: Optional[str] = None,
    partner: Optional[str] = None,
    property_obj: Optional[Dict[str, Any]] = None,
    headers_ext: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
    """Build request headers including signing and context.

    Args:
        sign (str): Hex-encoded signature.
        content_type (str): Content-Type header.
        uuid (Optional[str]): UUID value for tracing.
        account_id (Optional[str]): Account id.
        user_token (Optional[str]): User token.
        partner (Optional[str]): Partner identifier.
        property_obj (Optional[Dict[str, Any]]): Property payload.
        headers_ext (Optional[Dict[str, str]]): Extra headers to merge.

    Returns:
        Dict[str, str]: Complete headers dict.
    """

    did = uuid or str(_uuid.uuid4())
    headers = {
        "sign": sign,
        "From-Domain": FROM_DOMAIN,
        "Content-Type": content_type,
        "Accept": "application/json",
        "uuid": did,
    }
    if account_id:
        headers["account-id"] = account_id
    if user_token:
        headers["user-token"] = user_token
    if partner:
        headers["partner"] = partner

    prop = property_obj or {
        "frompageUrl": "",
        "pageUrl": "",
        "isLogin": "是" if bool(account_id) else "否",
        "accountid": account_id or "",
        "resumeId": "",
        "firstFrompageUrl": "",
        "distinct_id": did,
    }
    headers["property"] = quote(json.dumps(prop, ensure_ascii=False), safe="")
    if headers_ext:
        headers.update(headers_ext)
    return headers


def _request(
    method: str,
    path: str,
    params: Optional[Dict[str, Any]] = None,
    body: Optional[Dict[str, Any]] = None,
    uuid: Optional[str] = None,
    account_id: Optional[str] = None,
    user_token: Optional[str] = None,
    partner: Optional[str] = None,
    property_obj: Optional[Dict[str, Any]] = None,
    headers_ext: Optional[Dict[str, str]] = None,
    proxies: Optional[list] = None,
    timeout: int = 10,
    retries: int = 2,
    raw_sink: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]:
    """Execute signed HTTP request with basic retries.

    Args:
        method (str): HTTP method.
        path (str): API path without leading slash.
        params (Optional[Dict[str, Any]]): Query for GET.
        body (Optional[Dict[str, Any]]): JSON body for POST.
        uuid/account_id/user_token/partner/property_obj: Header context.
        headers_ext (Optional[Dict[str, str]]): Extra headers to merge.
        proxies (Optional[list]): Proxy entries.
        timeout (int): Timeout seconds.
        retries (int): Retry attempts.

    Returns:
        Dict[str, Any]: Parsed JSON response.
    """

    body_json = json.dumps(body, ensure_ascii=False) if body is not None else None
    sig = build_signature(method, path, params, body_json)
    content_type = "application/x-www-form-urlencoded" if method.upper() == "GET" else "application/json"
    headers = _build_headers(
        sign=sig["sig"],
        content_type=content_type,
        uuid=uuid,
        account_id=account_id,
        user_token=user_token,
        partner=partner,
        property_obj=property_obj,
        headers_ext=headers_ext,
    )
    url = f"{BASE_URL}{sig['signed_path']}"
    data_bytes = body_json.encode("utf-8") if (method.upper() == "POST" and body_json is not None) else None

    attempt = 0
    backoff = 0.5
    last_error: Optional[Exception] = None
    use_insecure_ssl = os.getenv("JOB_INSECURE_SSL") in ("1", "true", "TRUE")
    while attempt <= retries:
        _sleep_between_requests(0.2, 0.7)
        req = Request(url=url, data=data_bytes, headers=headers, method=method.upper())
        try:
            opener = None
            ctx = _get_ssl_context(use_insecure_ssl)
            if proxies:
                idx = attempt % len(proxies)
                p = proxies[idx]
                if isinstance(p, str):
                    ph = ProxyHandler({"http": p, "https": p})
                elif isinstance(p, dict):
                    ph = ProxyHandler(p)
                else:
                    ph = None
                if ph:
                    opener = build_opener(ph, HTTPSHandler(context=ctx))
            if opener:
                with opener.open(req, timeout=timeout) as resp:
                    payload = resp.read().decode("utf-8")
            else:
                with urlopen(req, timeout=timeout, context=ctx) as resp:
                    payload = resp.read().decode("utf-8")
            if raw_sink and isinstance(payload, str):
                try:
                    raw_sink(payload)
                except Exception:
                    pass
            return json.loads(payload) if payload else {}
        except (HTTPError, URLError) as e:
            print(e)
            last_error = e
            msg = str(e)
            if ("CERTIFICATE_VERIFY_FAILED" in msg) and (not use_insecure_ssl):
                use_insecure_ssl = True
                attempt += 1
                continue
            if attempt == retries:
                return {}
            time.sleep(backoff)
            backoff *= 2
            attempt += 1

    if last_error:
        return {}
    return {}


def search_company_keyword(
    keyword: str,
    page: int = 1,
    size: int = 20,
    job_area: str = "020000",
    sort_type: str = "0",
    search_type: str = "2",
    scene: str = "12",
    uuid: Optional[str] = None,
    account_id: Optional[str] = None,
    user_token: Optional[str] = None,
    partner: Optional[str] = None,
    property_obj: Optional[Dict[str, Any]] = None,
    raw_sink: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]:
    """Call open/noauth/search with a company keyword.

    Args:
        keyword (str): Keyword to search.
        page (int): Page number.
        size (int): Page size.
        job_area (str): Area code.
        sort_type (str): Sort type.
        search_type (str): Search type.
        scene (str): Scene id.
        uuid/account_id/user_token/partner/property_obj: Header context.

    Returns:
        Dict[str, Any]: API response JSON.
    """

    params: Dict[str, Any] = {
        "userLonLat": "",
        "sortType": sort_type,
        "keyword": keyword,
        "pageSize": str(size),
        "pageNum": str(page),
        "jobArea": job_area,
        "landmark": "",
        "radius": "",
        "workYear": "",
        "degree": "",
        "companyType": "",
        "companySize": "",
        "salary": "NaN-NaN",
        "jobType": "",
        "metro": "",
        "function": "",
        "industry": "",
        "issueDate": "",
        "searchType": search_type,
        "scene": scene,
    }
    return _request(
        method="GET",
        path="open/noauth/search",
        params=params,
        uuid=uuid,
        account_id=account_id,
        user_token=user_token,
        partner=partner,
        property_obj=property_obj,
        raw_sink=raw_sink,
    )


def company_jobs_by_id(
    co_id: str,
    page: int = 1,
    size: int = 20,
    uuid: Optional[str] = None,
    account_id: Optional[str] = None,
    user_token: Optional[str] = None,
    partner: Optional[str] = None,
    property_obj: Optional[Dict[str, Any]] = None,
    raw_sink: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]:
    """Fetch jobs for a company by numeric id using POST.

    Args:
        co_id (str): Numeric company id.
        page (int): Page number.
        size (int): Page size.
        uuid/account_id/user_token/partner/property_obj: Header context.

    Returns:
        Dict[str, Any]: API response JSON.
    """

    body: Dict[str, Any] = {
        "pageNum": page,
        "pageSize": size,
        "coId": co_id,
        "scene": 14,
        "requestId": "",
    }
    return _request(
        method="POST",
        path="open/noauth/jobs/company",
        body=body,
        uuid=uuid,
        account_id=account_id,
        user_token=user_token,
        partner=partner,
        property_obj=property_obj,
        raw_sink=raw_sink,
    )


def _extract_items(resp: Dict[str, Any]) -> list:
    """Extract the first list of items from a nested response.

    This function searches common keys first, then falls back to a recursive
    traversal to find the first list encountered. It is resilient to schema
    variations of the API response.

    Args:
        resp (Dict[str, Any]): Parsed JSON response.

    Returns:
        list: The extracted items list; empty when not found or no data.
    """

    if not isinstance(resp, dict):
        return []

    # Prefer job items under resultbody/job/items
    rb = resp.get("resultbody") or resp.get("resultBody")
    if isinstance(rb, dict):
        job_node = rb.get("job")
        if isinstance(job_node, dict) and isinstance(job_node.get("items"), list):
            return job_node.get("items", [])

    preferred_keys = (
        "items",
        "list",
        "jobs",
        "jobList",
        "companies",
        "companyList",
        "resultList",
        "dataList",
    )

    for key in preferred_keys:
        val = resp.get(key)
        if isinstance(val, list):
            return val

    def _walk(node: Any) -> Optional[list]:
        if isinstance(node, list):
            return node
        if isinstance(node, dict):
            for k in preferred_keys:
                v = node.get(k)
                if isinstance(v, list):
                    return v
            for v in node.values():
                found = _walk(v)
                if isinstance(found, list):
                    return found
        return None

    found = _walk(resp)
    return found or []


def _get_local_ip() -> str:
    """Get local IP address for forwarding header.

    Returns:
        str: Local IP string.
    """

    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"


def _get_ssl_context(insecure: bool = False) -> ssl.SSLContext:
    """Return SSL context, optionally unverified.

    Args:
        insecure (bool): Whether to disable certificate verification.

    Returns:
        ssl.SSLContext: Configured SSL context.
    """

    if insecure:
        try:
            return ssl._create_unverified_context()
        except Exception:
            ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            return ctx
    try:
        return ssl.create_default_context()
    except Exception:
        return ssl._create_unverified_context()

def _get_db_path() -> str:
    """Return default SQLite DB path for storing raw responses.

    Returns:
        str: Absolute file path to SQLite database.
    """

    base_dir = os.path.dirname(__file__)
    return os.path.join(base_dir, "qcwy_raw.sqlite3")


def _init_db(db_path: str) -> None:
    """Initialize SQLite database with responses table if absent.

    Args:
        db_path (str): Path to SQLite database file.

    Returns:
        None
    """

    try:
        con = sqlite3.connect(db_path)
        cur = con.cursor()
        cur.execute(
            """
            CREATE TABLE IF NOT EXISTS responses (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                keyword TEXT NOT NULL,
                page INTEGER NOT NULL,
                created_at INTEGER NOT NULL,
                payload TEXT NOT NULL
            )
            """
        )
        cur.execute(
            """
            CREATE UNIQUE INDEX IF NOT EXISTS idx_responses_keyword_page
            ON responses(keyword, page)
            """
        )
        con.commit()
        con.close()
    except Exception:
        pass


def _save_raw_response(db_path: str, keyword: str, page: int, raw_payload: str) -> None:
    """Persist raw HTTP response payload into SQLite without modification.

    Args:
        db_path (str): Path to SQLite database file.
        keyword (str): Search keyword.
        page (int): Page number for the response.
        raw_payload (str): Raw JSON text as received.

    Returns:
        None
    """

    try:
        con = sqlite3.connect(db_path)
        cur = con.cursor()
        cur.execute(
            "INSERT OR IGNORE INTO responses(keyword, page, created_at, payload) VALUES(?, ?, ?, ?)",
            (keyword, int(page), int(time.time()), raw_payload),
        )
        con.commit()
        con.close()
    except Exception:
        pass
def _has_page_record(db_path: str, keyword: str, page: int) -> bool:
    """Check if the given keyword+page already exists in SQLite."""

    try:
        con = sqlite3.connect(db_path)
        cur = con.cursor()
        cur.execute("SELECT 1 FROM responses WHERE keyword=? AND page=? LIMIT 1", (keyword, int(page)))
        row = cur.fetchone()
        con.close()
        return row is not None
    except Exception:
        return False

def _make_item_key(it: Any) -> str:
    """Return a stable deduplication key for an item."""

    try:
        if isinstance(it, dict):
            for k in ("jobId", "id", "job_id", "positionId"):
                v = it.get(k)
                if v is not None:
                    return f"id:{v}"
            return "hash:" + json.dumps(it, ensure_ascii=False, sort_keys=True)
        return "val:" + str(it)
    except Exception:
        return "val:" + str(it)
def _sleep_between_requests(min_seconds: float = 1.0, max_seconds: float = 3.0) -> None:
    """Sleep for a random duration between min_seconds and max_seconds.

    Args:
        min_seconds (float): Minimum seconds to sleep.
        max_seconds (float): Maximum seconds to sleep.

    Returns:
        None
    """

    try:
        dur = random.uniform(min_seconds, max_seconds)
        time.sleep(dur)
    except Exception:
        time.sleep(min_seconds)


def _record_company_success(company_name: Optional[str]) -> None:
    """Append successful company name to success log file once per process.

    Args:
        company_name (Optional[str]): Company name string.

    Returns:
        None
    """

    try:
        name = (company_name or "").strip()
        if not name or name in _SUCCESS_WRITTEN:
            return
        with open(SUCCESS_LOG_PATH, "a", encoding="utf-8") as f:
            f.write(f"{name}\n")
        _SUCCESS_WRITTEN.add(name)
    except Exception:
        pass


def _extract_company_name(info: Dict[str, Any]) -> Optional[str]:
    """Extract company name from company info payload across common keys.

    Args:
        info (Dict[str, Any]): Company info dict.

    Returns:
        Optional[str]: Company name when found.
    """

    keys = (
        "coname",
        "coName",
        "fullCompanyName",
        "companyName",
        "fullname",
        "name",
    )
    for k in keys:
        v = info.get(k)
        if isinstance(v, str) and v.strip():
            return v.strip()
    for parent in ("basicinfo", "basicInfo"):
        node = info.get(parent)
        if isinstance(node, dict):
            for k in keys:
                v = node.get(k)
                if isinstance(v, str) and v.strip():
                    return v.strip()
    return None

def _report_universal(items: list, data_type: str = "job") -> bool:
    """Post items list to universal batch-store-async endpoint.

    Args:
        items (list): Data list to send.
        data_type (str): Logical data type label.

    Returns:
        bool: True when accepted, else False.
    """

    return False


def _extract_total_count(resp: Dict[str, Any]) -> Optional[int]:
    """直接从响应中读取 job.totalCount 字段。

    Args:
        resp (Dict[str, Any]): 解析后的响应 JSON。

    Returns:
        Optional[int]: 总数，若不存在则为 None。
    """

    if not isinstance(resp, dict):
        return None

    def _direct_get(path: tuple) -> Optional[int]:
        node: Any = resp
        for key in path:
            if not isinstance(node, dict):
                return None
            node = node.get(key)
        if isinstance(node, dict):
            tc = node.get("totalCount")
            if isinstance(tc, int):
                return tc
            if isinstance(tc, str):
                s = tc.strip()
                if s.isdigit():
                    return int(s)
        return None

    for p in (
        ("resultbody", "job"),
        ("resultBody", "job"),
        ("job",),
        ("jobs",),
        ("result", "job"),
        ("data", "job"),
        ("payload", "job"),
    ):
        v = _direct_get(p)
        if isinstance(v, int):
            return v

    return None


def paginate_search_company_keyword(
    keyword: str,
    size: int = 20,
    job_area: str = "020000",
    sort_type: str = "0",
    search_type: str = "2",
    scene: str = "12",
    start_page: int = 1,
    max_pages: Optional[int] = None,
    delay: float = 0.2,
    verbose: bool = False,
    db_path: Optional[str] = None,
) -> list:
    """Iterate pages for company keyword search until no data.

    Args:
        keyword (str): Keyword to search.
        size (int): Page size per request.
        job_area (str): Area code.
        sort_type (str): Sort type.
        search_type (str): Search type.
        scene (str): Scene id.
        start_page (int): Starting page number.
        max_pages (Optional[int]): Maximum pages to fetch; None for unlimited.
        delay (float): Delay seconds between requests.
        verbose (bool): Whether to print per-page stats.

    Returns:
        list: Aggregated items across pages.
    """

    results: list = []
    seen_keys: set = set()
    page = start_page
    fetched_pages = 0
    total_count: Optional[int] = None

    db_path = db_path or _get_db_path()
    _init_db(db_path)

    while True:
        if max_pages is not None and fetched_pages >= max_pages:
            break

        if verbose:
            print(json.dumps({"fetching_page": page}, ensure_ascii=False))

        # Skip crawling when this page is already recorded
        if _has_page_record(db_path, keyword, page):
            if verbose:
                print(json.dumps({"page": page, "skipped": True}, ensure_ascii=False))
            page += 1
            fetched_pages += 1
            ms = delay if delay > 0 else 0.2
            mx = ms * 2
            _sleep_between_requests(ms, mx)
            continue

        def _sink(raw: str) -> None:
            _save_raw_response(db_path, keyword, page, raw)

        resp = search_company_keyword(
            keyword=keyword,
            page=page,
            size=size,
            job_area=job_area,
            sort_type=sort_type,
            search_type=search_type,
            scene=scene,
            raw_sink=_sink,
        )
        if verbose:
            print(json.dumps({"page": page, "saved": True}, ensure_ascii=False))
        if total_count is None:
            total_count = _extract_total_count(resp)
            if verbose and total_count is not None:
                print(json.dumps({"totalCount": total_count}, ensure_ascii=False))

        items = _extract_items(resp)
        filtered: list = []
        for it in items:
            key = _make_item_key(it)
            if key in seen_keys:
                continue
            seen_keys.add(key)
            filtered.append(it)
        if verbose:
            print(json.dumps({"page": page, "items_on_page": len(items), "unique_added": len(filtered)}, ensure_ascii=False))

        if not filtered:
            break
        results.extend(filtered)

        if total_count is not None:
            if len(results) >= total_count:
                break

        page += 1
        fetched_pages += 1
        ms = delay if delay > 0 else 0.2
        mx = ms * 2
        _sleep_between_requests(ms, mx)

    return results


# Static configuration for pagination demo
CONFIG: Dict[str, Any] = {
    "keyword": "字节跳动",
    "size": 20,
    "job_area": "020000",
    "sort_type": "0",
    "search_type": "2",
    "scene": "12",
    "start_page": 1,
    "max_pages": None,
    "delay": 0.2,
    "verbose": False,
    "db_path": None,
}


def main(keyword: str) -> None:
    """Run a demo of keyword search pagination until no data using static config.

    Returns:
        None
    """
    cfg = CONFIG

    results = paginate_search_company_keyword(
        keyword=keyword,
        size=cfg["size"],
        job_area=cfg["job_area"],
        sort_type=cfg["sort_type"],
        search_type=cfg["search_type"],
        scene=cfg["scene"],
        start_page=cfg["start_page"],
        max_pages=cfg["max_pages"],
        delay=cfg["delay"],
        verbose=cfg["verbose"],
        db_path=cfg["db_path"],
    )
    print(json.dumps({"total_items": len(results)}, ensure_ascii=False))


def get_company_info(company_id: str) -> Dict[str, Any]:
    """Fetch company details with caching.

    Args:
        company_id (str): Company identifier string.

    Returns:
        Dict[str, Any]: Company information dict; empty dict when not found.
    """

    if not company_id:
        return {}
    cached = COMPANY_INFO_CACHE.get(company_id)
    if isinstance(cached, dict) and cached:
        return cached

    params = {
        "companyId": company_id,
        "colorOne": "#ffffff",
        "colorTwo": "#ffffffcc",
    }
    property_obj = {
        "frompageUrl": "",
        "pageUrl": "",
        "isLogin": "否",
        "accountid": "",
        "resumeId": "",
        "firstFrompageUrl": "",
        "distinct_id": str(_uuid.uuid4()),
        "pageCode": "companyDetail|company|companyinfo",
        "shortPageCode": "companyDetail|company|companyinfo",
    }

    try:
        resp = _request(
            method="GET",
            path="open/noauth/company-info/info-data",
            params=params,
            property_obj=property_obj,
        )
    except Exception:
        resp = {}
    if resp and resp.get("status") in (1, "1"):
        info = resp.get("resultbody", {})
        if isinstance(info, dict) and info:
            COMPANY_INFO_CACHE[company_id] = info
            name = _extract_company_name(info)
            _record_company_success(name)
            return info
        return {}
    return {}


def _enrich_items_with_company_info(resp: Dict[str, Any]) -> list:
    """Attach company info to items using cache.

    Args:
        resp (Dict[str, Any]): Parsed JSON response.

    Returns:
        list: Items with company_info fields when available.
    """
    items = resp.get("resultbody", {}).get("job", {}).get("items", [])
    enriched = []
    for it in items:
        target = dict(it) if isinstance(it, dict) else {"_value": it}
        co_id = target.get("coId") or target.get("companyId")
        job_id = target.get("jobId")
        city_pinyin = target.get("hrefAreaPinYin")
        if co_id:
            info = get_company_info(str(co_id))
            if info:
                target["company_info"] = info
                target["company_desc"] = (info.get("coinfo", {}) or {}).get("coinfo")
                target["companyHref"] = (info.get("share", {}) or {}).get("weixinshareurl")
                target["jobHref"] = f"https://jobs.51job.com/{city_pinyin}/{job_id}.html"
                nm = _extract_company_name(info) or target.get("fullCompanyName") or target.get("companyName")
                _record_company_success(nm)
            _sleep_between_requests()
        enriched.append(target)
    return enriched


def _load_keywords(path: str) -> list:
    """Load keywords from a UTF-8 text file, one per line.

    Args:
        path (str): File path.

    Returns:
        list: Non-empty trimmed lines.
    """

    try:
        with open(path, "r", encoding="utf-8") as f:
            lines = [ln.strip() for ln in f.readlines()]
            return [ln for ln in lines if ln]
    except Exception:
        return []


def _progress_iter(seq: list, desc: str = "", total: Optional[int] = None):
    """Iterate with a simple console progress bar.

    Args:
        seq (list): Items to iterate.
        desc (str): Progress description.
        total (Optional[int]): Total count for percentage.

    Yields:
        Any: Items from seq.
    """

    n = 0
    m = total if total is not None else len(seq)
    bar_len = 24
    for item in seq:
        n += 1
        filled = int(bar_len * n / m) if m else 0
        bar = "#" * filled + "-" * (bar_len - filled)
        pct = int(100 * n / m) if m else 100
        print(f"\r{desc} [{bar}] {n}/{m} {pct}%", end="", flush=True)
        yield item
    print("", flush=True)


if __name__ == "__main__":
    base_dir = os.path.dirname(__file__)
    fp = os.path.join(base_dir, "company.txt")
    td = _load_keywords(fp) or [CONFIG.get("keyword")]
    for keyword in _progress_iter(td, desc="Keywords", total=len(td)):
        print(keyword)
        main(keyword)
        _sleep_between_requests()
