Beispiel #1
0
class Linkedin(object):
    """
    Class for accessing Linkedin API.
    """

    _MAX_UPDATE_COUNT = 100  # max seems to be 100
    _MAX_SEARCH_COUNT = 49  # max seems to be 49
    _MAX_REPEATED_REQUESTS = (
        200)  # VERY conservative max requests count to avoid rate-limit

    def __init__(self, username, password):
        self.client = Client()
        self.client.authenticate(username, password)

        self.logger = logger

    def search(self, params, max_results=None, results=[]):
        """
        Do a search.
        """
        sleep(random.randint(
            0, 1))  # sleep a random duration to try and evade suspention

        count = (max_results
                 if max_results and max_results <= Linkedin._MAX_SEARCH_COUNT
                 else Linkedin._MAX_SEARCH_COUNT)
        default_params = {
            "count": count,
            "guides": "List()",
            "origin": "GLOBAL_SEARCH_HEADER",
            "q": "guided",
            "start": len(results),
        }

        default_params.update(params)

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/search/cluster",
            params=default_params)
        data = res.json()

        total_found = data.get("paging", {}).get("total")

        # recursive base case
        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results)
                or total_found is None or len(results) >= total_found or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"][0]["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.search(params, results=results, max_results=max_results)

    def search_people(
        self,
        keywords=None,
        connection_of=None,
        network_depth=None,
        regions=None,
        industries=None,
    ):
        """
        Do a people search.
        """
        guides = ["v->PEOPLE"]
        if connection_of:
            guides.append(f"facetConnectionOf->{connection_of}")
        if network_depth:
            guides.append(f"facetNetwork->{network_depth}")
        if regions:
            guides.append(f'facetGeoRegion->{"|".join(regions)}')
        if industries:
            guides.append(f'facetIndustry->{"|".join(industries)}')

        params = {"guides": "List({})".format(",".join(guides))}

        if keywords:
            params["keywords"] = keywords

        data = self.search(params)

        results = []
        for item in data:
            search_profile = item["hitInfo"][
                "com.linkedin.voyager.search.SearchProfile"]
            profile_id = search_profile["id"]
            distance = search_profile["distance"]["value"]

            results.append({
                "urn_id":
                profile_id,
                "distance":
                distance,
                "public_id":
                search_profile["miniProfile"]["publicIdentifier"],
            })

        return results

    def get_profile_contact_info(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/identity/profiles/{public_id or urn_id}/profileContactInfo"
        )
        data = res.json()

        contact_info = {
            "email_address": data.get("emailAddress"),
            "websites": [],
            "phone_numbers": data.get("phoneNumbers", []),
        }

        websites = data.get("websites", [])
        for item in websites:
            if "com.linkedin.voyager.identity.profile.StandardWebsite" in item[
                    "type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.StandardWebsite"][
                        "category"]
            elif "" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.CustomWebsite"][
                        "label"]

            del item["type"]

        contact_info["websites"] = websites

        return contact_info

    def get_profile(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        sleep(random.randint(
            2, 5))  # sleep a random duration to try and evade suspention
        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/identity/profiles/{public_id or urn_id}/profileView"
        )

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        # massage [profile] data
        profile = data["profile"]
        if "miniProfile" in profile:
            if "picture" in profile["miniProfile"]:
                profile["displayPictureUrl"] = profile["miniProfile"][
                    "picture"]["com.linkedin.common.VectorImage"]["rootUrl"]
            profile["profile_id"] = get_id_from_urn(
                profile["miniProfile"]["entityUrn"])

            del profile["miniProfile"]

        del profile["defaultLocale"]
        del profile["supportedLocales"]
        del profile["versionTag"]
        del profile["showEducationOnProfileTopCard"]

        # massage [experience] data
        experience = data["positionView"]["elements"]
        for item in experience:
            if "company" in item and "miniCompany" in item["company"]:
                if "logo" in item["company"]["miniCompany"]:
                    logo = item["company"]["miniCompany"]["logo"].get(
                        "com.linkedin.common.VectorImage")
                    if logo:
                        item["companyLogoUrl"] = logo["rootUrl"]
                del item["company"]["miniCompany"]

        profile["experience"] = experience

        # massage [skills] data
        skills = [item["name"] for item in data["skillView"]["elements"]]

        profile["skills"] = skills

        # massage [education] data
        education = data["educationView"]["elements"]
        for item in education:
            if "school" in item:
                if "logo" in item["school"]:
                    item["school"]["logoUrl"] = item["school"]["logo"][
                        "com.linkedin.common.VectorImage"]["rootUrl"]
                    del item["school"]["logo"]

        profile["education"] = education

        return profile

    def get_profile_connections(self, urn_id):
        """
        Return a list of profile ids connected to profile of given [urn_id]
        """
        return self.search_people(connection_of=urn_id, network_depth="F")

    def get_company_updates(self,
                            public_id=None,
                            urn_id=None,
                            max_results=None,
                            results=[]):
        """"
        Return a list of company posts

        [public_id] - public identifier ie - microsoft
        [urn_id] - id provided by the related URN
        """
        sleep(random.randint(
            2, 5))  # sleep a random duration to try and evade suspention

        params = {
            "companyUniversalName": {public_id or urn_id},
            "q": "companyFeedByUniversalName",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/feed/updates", params=params)

        data = res.json()

        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results) or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_company_updates(public_id=public_id,
                                        urn_id=urn_id,
                                        results=results,
                                        max_results=max_results)

    def get_profile_updates(self,
                            public_id=None,
                            urn_id=None,
                            max_results=None,
                            results=[]):
        """"
        Return a list of profile posts

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        sleep(random.randint(
            2, 5))  # sleep a random duration to try and evade suspention

        params = {
            "profileId": {public_id or urn_id},
            "q": "memberShareFeed",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/feed/updates", params=params)

        data = res.json()

        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results) or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_profile_updates(public_id=public_id,
                                        urn_id=urn_id,
                                        results=results,
                                        max_results=max_results)

    def get_current_profile_views(self):
        """
        Get profile view statistics, including chart data.
        """
        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/identity/panels")

        data = res.json()

        return data['elements'][0]['value'][
            'com.linkedin.voyager.identity.me.ProfileViewsByTimePanel']

    def get_school(self, public_id):
        """
        Return data for a single school.

        [public_id] - public identifier i.e. uq
        """
        sleep(random.randint(
            2, 5))  # sleep a random duration to try and evade suspention
        params = {
            "decoration": ("""
                (
                autoGenerated,backgroundCoverImage,
                companyEmployeesSearchPageUrl,companyPageUrl,confirmedLocations*,coverPhoto,dataVersion,description,
                entityUrn,followingInfo,foundedOn,headquarter,jobSearchPageUrl,lcpTreatment,logo,name,type,overviewPhoto,
                paidCompany,partnerCompanyUrl,partnerLogo,partnerLogoImage,rankForTopCompanies,salesNavigatorCompanyUrl,
                school,showcase,staffCount,staffCountRange,staffingCompany,topCompaniesListName,universalName,url,
                companyIndustries*,industries,specialities,
                acquirerCompany~(entityUrn,logo,name,industries,followingInfo,url,paidCompany,universalName),
                affiliatedCompanies*~(entityUrn,logo,name,industries,followingInfo,url,paidCompany,universalName),
                groups*~(entityUrn,largeLogo,groupName,memberCount,websiteUrl,url),
                showcasePages*~(entityUrn,logo,name,industries,followingInfo,url,description,universalName)
                )
                """),
            "q":
            "universalName",
            "universalName":
            public_id,
        }

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/organization/companies",
            params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        school = data["elements"][0]

        return school

    def get_company(self, public_id):
        """
        Return data for a single company.

        [public_id] - public identifier i.e. univeristy-of-queensland
        """
        sleep(random.randint(
            2, 5))  # sleep a random duration to try and evade suspention
        params = {
            "decoration": ("""
                (
                affiliatedCompaniesWithEmployeesRollup,affiliatedCompaniesWithJobsRollup,articlePermalinkForTopCompanies,
                autoGenerated,backgroundCoverImage,companyEmployeesSearchPageUrl,
                companyPageUrl,confirmedLocations*,coverPhoto,dataVersion,description,entityUrn,followingInfo,
                foundedOn,headquarter,jobSearchPageUrl,lcpTreatment,logo,name,type,overviewPhoto,paidCompany,
                partnerCompanyUrl,partnerLogo,partnerLogoImage,permissions,rankForTopCompanies,
                salesNavigatorCompanyUrl,school,showcase,staffCount,staffCountRange,staffingCompany,
                topCompaniesListName,universalName,url,companyIndustries*,industries,specialities,
                acquirerCompany~(entityUrn,logo,name,industries,followingInfo,url,paidCompany,universalName),
                affiliatedCompanies*~(entityUrn,logo,name,industries,followingInfo,url,paidCompany,universalName),
                groups*~(entityUrn,largeLogo,groupName,memberCount,websiteUrl,url),
                showcasePages*~(entityUrn,logo,name,industries,followingInfo,url,description,universalName)
                )
                """),
            "q":
            "universalName",
            "universalName":
            public_id,
        }

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/organization/companies",
            params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        company = data["elements"][0]

        return company

    def get_conversation_details(self, profile_urn_id):
        """
        Return the conversation (or "message thread") details for a given [public_profile_id]
        """
        # passing `params` doesn't work properly, think it's to do with List().
        # Might be a bug in `requests`?
        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/messaging/conversations?\
            keyVersion=LEGACY_INBOX&q=participants&recipients=List({profile_urn_id})"
        )

        data = res.json()

        item = data["elements"][0]
        item["id"] = get_id_from_urn(item["entityUrn"])

        return item

    def get_conversations(self):
        """
        Return list of conversations the user is in.
        """
        params = {"keyVersion": "LEGACY_INBOX"}

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/messaging/conversations",
            params=params)

        return res.json()

    def get_conversation(self, conversation_urn_id):
        """
        Return the full conversation at a given [conversation_urn_id]
        """
        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/messaging/conversations/{conversation_urn_id}/events"
        )

        return res.json()

    def send_message(self, conversation_urn_id, message_body):
        """
        Send a message to a given conversation. If error, return true.
        """
        params = {"action": "create"}

        payload = json.dumps({
            "eventCreate": {
                "value": {
                    "com.linkedin.voyager.messaging.create.MessageCreate": {
                        "body": message_body,
                        "attachments": [],
                        "attributedBody": {
                            "text": message_body,
                            "attributes": []
                        },
                        "mediaAttachments": [],
                    }
                }
            }
        })

        res = self.client.session.post(
            f"{self.client.API_BASE_URL}/messaging/conversations/{conversation_urn_id}/events",
            params=params,
            data=payload,
        )

        return res.status_code != 201

    def mark_conversation_as_seen(self, conversation_urn_id):
        """
        Send seen to a given conversation. If error, return True.
        """
        payload = json.dumps({"patch": {"$set": {"read": True}}})

        res = self.client.session.post(
            f"{self.client.API_BASE_URL}/messaging/conversations/{conversation_urn_id}",
            data=payload)

        return res.status_code != 200
Beispiel #2
0
class Linkedin(object):
    """
    Class for accessing Linkedin API.
    """

    _MAX_UPDATE_COUNT = 100  # max seems to be 100
    _MAX_SEARCH_COUNT = 49  # max seems to be 49
    _MAX_SEARCH_RETURNED = 1000
    _MAX_REPEATED_REQUESTS = (
        200)  # VERY conservative max requests count to avoid rate-limit

    def __init__(self, username, password, refresh_cookies=False, debug=False):
        self.client = Client(refresh_cookies=refresh_cookies, debug=debug)
        self.proxies = self.client.proxies
        self.client.authenticate(username, password)
        logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
        self.logger = logger

    def _fetch(self, uri, **kwargs):
        """
        GET request to Linkedin API
        """
        default_evade(start=0, end=0)

        url = f"{self.client.API_BASE_URL}{uri}"
        return self.client.session.get(url, proxies=self.proxies, **kwargs)

    def _post(self, uri, **kwargs):
        """
        POST request to Linkedin API
        """
        default_evade(start=0, end=0)

        url = f"{self.client.API_BASE_URL}{uri}"
        return self.client.session.post(url, proxies=self.proxies, **kwargs)

    def get_current_profile(self):
        """
        GET current profile
        """
        response = self._fetch(
            f'/me/',
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"})
        data = response.json()

        profile = {
            'firstName': data['included'][0]['firstName'],
            'lastName': data['included'][0]['lastName'],
            'publicIdentifier': data['included'][0]['publicIdentifier'],
            'occupation': data['included'][0]['occupation'],
            'message_id': data['included'][0]['entityUrn'].split(':')[3],
            'is_premium': data.get('data').get('premiumSubscriber'),
        }

        try:
            profile['avatarUrl'] = data['included'][0]['picture']['rootUrl'] + \
                data['included'][0]['picture']['artifacts'][2]['fileIdentifyingUrlPathSegment']
        except TypeError:
            profile['avatarUrl'] = None

        return profile

    def search(self, params, limit=None, results=[]):
        """
        Do a search.
        """
        count = (limit if limit and limit <= Linkedin._MAX_SEARCH_COUNT else
                 Linkedin._MAX_SEARCH_COUNT)
        default_params = {
            "count":
            str(count),
            "filters":
            "List()",
            "origin":
            "GLOBAL_SEARCH_HEADER",
            "q":
            "all",
            "start":
            len(results),
            "queryContext":
            "List(spellCorrectionEnabled->true,relatedSearchesEnabled->true,kcardTypes->PROFILE|COMPANY)",
        }

        default_params.update(params)

        res = self._fetch(
            f"/search/blended?{urlencode(default_params)}",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        data = res.json()

        new_elements = []
        for i in range(len(data["data"]["elements"])):
            new_elements.extend(data["data"]["elements"][i]["elements"])
            # not entirely sure what extendedElements generally refers to - keyword search gives back a single job?
            # new_elements.extend(data["data"]["elements"][i]["extendedElements"])

        results.extend(new_elements)
        results = results[:
                          limit]  # always trim results, no matter what the request returns

        # recursive base case
        if (limit is not None and
            (len(results) >= limit  # if our results exceed set limit
             or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS)
            ) or len(new_elements) == 0:
            return results

        self.logger.debug(f"results grew to {len(results)}")

        return self.search(params, results=results, limit=limit)

    def search_voyager(self,
                       limit=None,
                       results=[],
                       start=0,
                       keys=None,
                       industries=None,
                       profileLanguages=None,
                       networkDepth=None,
                       title="",
                       firstName="",
                       lastName="",
                       currentCompanies=None,
                       schools=None,
                       regions=None,
                       past_companies=None,
                       company=None,
                       school=None,
                       connection_of=None):
        """
        Default search
        """
        count = (limit if limit and limit <= Linkedin._MAX_SEARCH_COUNT else
                 Linkedin._MAX_SEARCH_COUNT)

        default_params = {
            "count":
            str(limit),
            "filters":
            "List()",
            "origin":
            "CLUSTER_EXPANSION",
            "q":
            "all",
            "start":
            str(start),
            "queryContext":
            "List(spellCorrectionEnabled->true,relatedSearchesEnabled->true,kcardTypes->PROFILE|COMPANY)",
        }

        if past_companies is None:
            default_params["past_companies"] = ""
        else:
            default_params["origin"] = "FACETED_SEARCH"
            default_params["past_companies"] = "pastCompany->{},".format(
                "|".join(past_companies))

        if networkDepth is None:
            default_params['network_depth'] = ""
        else:
            default_params["origin"] = "FACETED_SEARCH"
            default_params["network_depth"] = "network->{},".format(
                "|".join(networkDepth))

        if connection_of is None:
            default_params['connection_of'] = ""
        else:
            default_params["origin"] = "FACETED_SEARCH"
            default_params["connection_of"] = "connectionOf->{},".format(
                "|".join(connection_of))

        if profileLanguages is None:
            default_params['profileLanguages'] = ""
        else:
            default_params["origin"] = "FACETED_SEARCH"
            default_params["profileLanguages"] = "profileLanguage->{},".format(
                "|".join(profileLanguages))

        if regions is None:
            default_params["regions"] = ""
        else:
            default_params["origin"] = "FACETED_SEARCH"
            default_params["regions"] = "geoRegion->{},".format(
                "|".join(regions))

        if keys is None:
            default_params["keys"] = ""
        else:
            default_params["origin"] = "FACETED_SEARCH"
            default_params["keys"] = keys

        if industries is None:
            default_params["industries"] = ""
        else:

            default_params["origin"] = "FACETED_SEARCH"
            default_params["industries"] = "industry->{},".format(
                "|".join(industries))

        if title:
            default_params['title'] = ",title->{}".format(title)
            default_params["origin"] = "FACETED_SEARCH"
        else:
            default_params["title"] = ""

        if firstName:
            default_params['firstName'] = ",firstName->{}".format(firstName)
            default_params["origin"] = "FACETED_SEARCH"
        else:
            default_params["firstName"] = ""

        if lastName:
            default_params['lastName'] = ",lastName->{}".format(lastName)
            default_params["origin"] = "FACETED_SEARCH"
        else:
            default_params["lastName"] = ""

        if currentCompanies is None:
            default_params["currentCompanies"] = ""
        else:
            default_params['currentCompanies'] = ",currentCompany->{}".format(
                "|".join(currentCompanies))
            default_params["origin"] = "FACETED_SEARCH"

        if schools is None:
            default_params["schools"] = ""
        else:
            default_params['schools'] = ",school->{}".format("|".join(schools))
            default_params["origin"] = "FACETED_SEARCH"

        if company is None:
            default_params["company"] = ""
        else:
            default_params["company"] = ",company->{}".format(company)
            default_params["origin"] = "FACETED_SEARCH"

        if school is None:
            default_params["school"] = ""
        else:
            default_params["school"] = ",school->{}".format(school)
            default_params["origin"] = "FACETED_SEARCH"

        res = self._fetch(
            f"/search/blended?count=" + str(limit) + "&filters=List(" +
            default_params["connection_of"] +
            default_params["past_companies"] + default_params["regions"] +
            default_params['industries'] + default_params['network_depth'] +
            default_params['profileLanguages'] + "resultType-%3EPEOPLE" +
            default_params["school"] + default_params["company"] +
            default_params["firstName"] + default_params["lastName"] +
            default_params["title"] + default_params["currentCompanies"] +
            default_params["schools"] + ")&keywords=" +
            default_params['keys'] + "%20&origin=" + default_params["origin"] +
            "&q=all&queryContext=List(spellCorrectionEnabled-%3Etrue,relatedSearchesEnabled-%3Etrue)&start="
            + default_params['start'],
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        data = res.json()

        try:
            if not data:
                return []
        except AttributeError:
            return []

        return data

        new_elements = []

        for i in range(len(data["data"]["elements"])):
            new_elements.extend(data["data"]["elements"][i]["elements"])
        # not entirely sure what extendedElements generally refers to - keyword search gives back a single job?
        # new_elements.extend(data["data"]["elements"][i]["extendedElements"])

        results.extend(new_elements)
        results = results[:
                          limit]  # always trim results, no matter what the request returns

        # recursive base case
        if (limit is not None and
            (len(results) >= limit  # if our results exceed set limit
             or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS)
            ) or len(new_elements) == 0:
            return results

        self.logger.debug(f"results grew to {len(results)}")

        return data

    def search_people(
            self,
            keywords=None,
            connection_of=None,
            networkDepth=None,
            currentCompanies=None,
            past_companies=None,
            nonprofit_interests=None,
            profileLanguages=None,
            regions=None,
            industries=None,
            schools=None,
            # profiles without a public id, "Linkedin Member"
            include_private_profiles=False,
            limit=None,
            start=None,
            keys=None,
            title="",
            firstName="",
            lastName="",
            company=None,
            school=None):
        """
        Do a people search.
        """

        data = self.search_voyager(limit=limit,
                                   results=[],
                                   start=start,
                                   keys=keywords,
                                   industries=industries,
                                   profileLanguages=profileLanguages,
                                   networkDepth=networkDepth,
                                   title=title,
                                   firstName=firstName,
                                   lastName=lastName,
                                   currentCompanies=currentCompanies,
                                   schools=schools,
                                   regions=regions,
                                   past_companies=past_companies,
                                   company=company,
                                   school=school,
                                   connection_of=connection_of)

        if not data:
            return 0, []

        try:
            number = data.get('data').get('metadata').get('totalResultCount')

            if number > Linkedin._MAX_SEARCH_RETURNED:
                number = Linkedin._MAX_SEARCH_RETURNED

            users_data = data.get("data").get("elements")[0].get("elements")
            uncluded_data = [
                included for included in data.get("included")
                if "publicIdentifier" in included
            ]
        except:
            return 0, []

        users = []

        for user_data in users_data:
            for included in uncluded_data:
                if user_data.get("targetUrn") == included.get("entityUrn"):
                    users.append({
                        "urn_id": user_data.get("targetUrn"),
                        "data": user_data,
                        "included": included
                    })

        results = []

        for user in users:
            try:
                public_id = user.get("data", {}).get("publicIdentifier", "")
            except TypeError:
                public_id = ""
            try:
                first_name = user.get("included", {}).get("firstName", "")
            except TypeError:
                first_name = ""
            try:
                last_name = user.get("included", {}).get("lastName", "")
            except TypeError:
                last_name = ""
            try:
                headline = user.get("data", {}).get("headline",
                                                    {}).get("text", "")
            except TypeError:
                headline = ""
            try:
                snippet = user.get("data", {}).get("snippetText",
                                                   {}).get("text", "")
            except TypeError:
                snippet = ""
            try:
                location = user.get("data", {}).get("subline",
                                                    {}).get("text", "")
            except TypeError:
                location = ""
            try:
                network_depth = user.get("data", {}).get("secondaryTitle",
                                                         {}).get("text", "")
            except TypeError:
                network_depth = ""
            try:
                display_picture_url = user.get("included", {}).get(
                    "picture", {}).get("rootUrl", "") + user.get(
                        "included", {}).get("picture", {}).get(
                            "artifacts", [
                                {},
                            ])[0].get("fileIdentifyingUrlPathSegment", {})
                if display_picture_url is None:
                    display_picture_url = ""
            except:
                display_picture_url = ""
            try:
                navigation_url = user.get("data", {}).get("navigationUrl", "")
            except TypeError:
                navigation_url = ""

            results.append({
                "urn_id": user.get("urn_id").split(':')[-1],
                "public_id": public_id,
                "first_name": first_name,
                "last_name": last_name,
                "headline": headline,
                "snippet": snippet,
                "location": location,
                "network_depth": network_depth,
                "displayPictureUrl": display_picture_url,
                "navigation_url": navigation_url
            })

        return number, results

    def get_current_profile_connections(self, start=None):

        res = self._fetch(
            f'/search/blended?count=10&filters=List(network-%3EF,resultType-%3EPEOPLE)&origin=MEMBER_PROFILE_CANNED_SEARCH&q=all&queryContext=List(spellCorrectionEnabled-%3Etrue,relatedSearchesEnabled-%3Etrue)&start='
            + str(start),
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        data = res.json()

        data = data.get("included")[10:]

        return data

    def get_quantity_of_current_profile_connections(self):
        res = self._fetch(
            f'/search/blended?count=10&filters=List(network-%3EF,resultType-%3EPEOPLE)&origin=MEMBER_PROFILE_CANNED_SEARCH&q=all&queryContext=List(spellCorrectionEnabled-%3Etrue,relatedSearchesEnabled-%3Etrue)&start=0',
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        data = res.json()

        count_of_connections = data.get("data").get("metadata").get(
            "totalResultCount")

        return count_of_connections

    def get_profile_contact_info(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        res = self._fetch(
            f"/identity/profiles/{public_id or urn_id}/profileContactInfo")
        data = res.json()

        contact_info = {
            "email_address": data.get("emailAddress"),
            "websites": [],
            "twitter": data.get("twitterHandles"),
            "birthdate": data.get("birthDateOn"),
            "ims": data.get("ims"),
            "phone_numbers": data.get("phoneNumbers", []),
        }

        websites = data.get("websites", [])
        for item in websites:
            if "com.linkedin.voyager.identity.profile.StandardWebsite" in item[
                    "type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.StandardWebsite"][
                        "category"]
            elif "" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.CustomWebsite"][
                        "label"]

            del item["type"]

        contact_info["websites"] = websites

        return contact_info

    def get_profile_skills(self, public_id=None, urn_id=None):
        """
        Return the skills of a profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        params = {"count": 100, "start": 0}
        res = self._fetch(f"/identity/profiles/{public_id or urn_id}/skills",
                          params=params)
        data = res.json()

        skills = data.get("elements", [])
        for item in skills:
            del item["entityUrn"]

        return skills

    def get_profile(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        res = self._fetch(
            f"/identity/profiles/{public_id or urn_id}/profileView")

        data = res.json()
        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        # massage [profile] data

        profile = data["profile"]

        try:
            avatarUrl = data.get("profile").get('miniProfile').get(
                'picture').get('com.linkedin.common.VectorImage').get(
                    'artifacts')[0].get('fileIdentifyingUrlPathSegment')
        except AttributeError:
            avatarUrl = ""

        if "miniProfile" in profile:
            if "picture" in profile["miniProfile"]:
                profile["display_picture_url"] = profile["miniProfile"][
                    "picture"]["com.linkedin.common.VectorImage"][
                        "rootUrl"] + avatarUrl
            profile["profile_id"] = get_id_from_urn(
                profile["miniProfile"]["entityUrn"])

            del profile["miniProfile"]

        del profile["defaultLocale"]
        del profile["supportedLocales"]
        del profile["versionTag"]
        del profile["showEducationOnProfileTopCard"]

        # massage [experience] data
        experience = data["positionView"]["elements"]
        for item in experience:
            if "company" in item and "miniCompany" in item["company"]:
                if "logo" in item["company"]["miniCompany"]:
                    logo = item["company"]["miniCompany"]["logo"].get(
                        "com.linkedin.common.VectorImage")
                    if logo:
                        item["companyLogoUrl"] = logo["rootUrl"]
                del item["company"]["miniCompany"]

        profile["experience"] = experience

        # massage [skills] data
        # skills = [item["name"] for item in data["skillView"]["elements"]]
        # profile["skills"] = skills

        profile["skills"] = self.get_profile_skills(public_id=public_id,
                                                    urn_id=urn_id)

        # massage [education] data
        education = data["educationView"]["elements"]
        for item in education:
            if "school" in item:
                if "logo" in item["school"]:
                    item["school"]["logoUrl"] = item["school"]["logo"][
                        "com.linkedin.common.VectorImage"]["rootUrl"]
                    del item["school"]["logo"]

        profile["education"] = education

        return profile

    def get_profile_connections(self, urn_id):
        """
        Return a list of profile ids connected to profile of given [urn_id]
        """
        return self.search_people(connection_of=urn_id, networkDepth="F")[1]

    def get_company_updates(self,
                            public_id=None,
                            urn_id=None,
                            max_results=None,
                            results=[]):
        """"
        Return a list of company posts

        [public_id] - public identifier ie - microsoft
        [urn_id] - id provided by the related URN
        """
        params = {
            "companyUniversalName": {public_id or urn_id},
            "q": "companyFeedByUniversalName",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch(f"/feed/updates", params=params)

        data = res.json()

        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results) or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_company_updates(public_id=public_id,
                                        urn_id=urn_id,
                                        results=results,
                                        max_results=max_results)

    def get_profile_updates(self,
                            public_id=None,
                            urn_id=None,
                            max_results=None,
                            results=[]):
        """"
        Return a list of profile posts

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        params = {
            "profileId": {public_id or urn_id},
            "q": "memberShareFeed",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch(f"/feed/updates", params=params)

        data = res.json()

        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results) or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_profile_updates(public_id=public_id,
                                        urn_id=urn_id,
                                        results=results,
                                        max_results=max_results)

    def get_current_profile_views(self):
        """
        Get profile view statistics, including chart data.
        """
        res = self._fetch(f"/identity/wvmpCards")

        data = res.json()

        return data["elements"][0]["value"][
            "com.linkedin.voyager.identity.me.wvmpOverview.WvmpViewersCard"][
                "insightCards"][0]["value"][
                    "com.linkedin.voyager.identity.me.wvmpOverview.WvmpSummaryInsightCard"][
                        "numViews"]

    def get_school(self, public_id):
        """
        Return data for a single school.

        [public_id] - public identifier i.e. uq
        """
        params = {
            "decorationId":
            "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch(f"/organization/companies?{urlencode(params)}")

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data))
            return {}

        school = data["elements"][0]

        return school

    def get_company(self, public_id):
        """
        Return data for a single company.

        [public_id] - public identifier i.e. univeristy-of-queensland
        """
        params = {
            "decorationId":
            "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch(f"/organization/companies", params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        company = data["elements"][0]

        return company

    def get_conversation_details(self, profile_urn_id):
        """
        Return the conversation (or "message thread") details for a given [public_profile_id]
        """
        # passing `params` doesn't work properly, think it's to do with List().
        # Might be a bug in `requests`?
        res = self._fetch(f"/messaging/conversations?\
            keyVersion=LEGACY_INBOX&q=participants&recipients=List({profile_urn_id})"
                          )

        data = res.json()

        item = data["elements"][0]
        item["id"] = get_id_from_urn(item["entityUrn"])

        return item

    def get_conversations(self):
        """
        Return list of conversations the user is in.
        """
        params = {"keyVersion": "LEGACY_INBOX"}

        res = self._fetch(f"/messaging/conversations", params=params)

        return res.json()

    def get_conversation(self, conversation_urn_id):
        """
        Return the full conversation at a given [conversation_urn_id]
        """
        res = self._fetch(
            f"/messaging/conversations/{conversation_urn_id}/events")

        return res.json()

    def get_conversation_id(self, public_id=None):
        """
        Return the last conversation_urn_id with user at given [public_id]
        """
        params = {"keyVersion": "LEGACY_INBOX"}

        res = self._fetch(f"/messaging/conversations", params=params)

        conversations = res.json().get('elements', [])

        for conversation in conversations:
            if len(conversation.get(
                    'participants',
                [])) == 1 and conversation.get('participants', [
                    {},
                ])[0].get('com.linkedin.voyager.messaging.MessagingMember',
                          {}).get('miniProfile', {}).get(
                              'publicIdentifier', None) == public_id:
                return conversation.get('entityUrn').split(':')[-1]
        return None

    def is_replied(self, public_id=None):
        """
            Return true if user replied you
        """
        conversation_id = self.get_conversation_id(public_id)

        if conversation_id is None:
            return False

        conversation = self.get_conversation(conversation_id)

        messages = conversation.get("elements")

        if messages is None:
            return False

        last_message = messages[len(messages) - 1]

        message_public_id = last_message.get('from').get(
            'com.linkedin.voyager.messaging.MessagingMember').get(
                'miniProfile').get('publicIdentifier')

        return message_public_id == public_id

    def send_message(self,
                     conversation_urn_id=None,
                     recipients=None,
                     message_body=None):
        """
        Send a message to a given conversation. If error, return true.

        Recipients: List of profile urn id's
        """
        params = {"action": "create"}

        if not (conversation_urn_id or recipients) and not message_body:
            return True

        message_event = {
            "eventCreate": {
                "value": {
                    "com.linkedin.voyager.messaging.create.MessageCreate": {
                        "body": message_body,
                        "attachments": [],
                        "attributedBody": {
                            "text": message_body,
                            "attributes": []
                        },
                        "mediaAttachments": [],
                    }
                }
            }
        }

        if conversation_urn_id:
            res = self._post(
                f"/messaging/conversations/{conversation_urn_id}/events",
                params=params,
                data=json.dumps(message_event),
            )
        elif recipients and not conversation_urn_id:
            message_event["recipients"] = recipients
            message_event["subtype"] = "MEMBER_TO_MEMBER"
            payload = {
                "keyVersion": "LEGACY_INBOX",
                "conversationCreate": message_event,
            }
            res = self._post(f"/messaging/conversations",
                             params=params,
                             data=json.dumps(payload))

        return res.status_code != 201

    def mark_conversation_as_seen(self, conversation_urn_id):
        """
        Send seen to a given conversation. If error, return True.
        """
        payload = json.dumps({"patch": {"$set": {"read": True}}})

        res = self._post(f"/messaging/conversations/{conversation_urn_id}",
                         data=payload)

        return res.status_code != 200

    def get_user_profile(self):
        """"
        Return current user profile
        """
        default_evade(
            start=0,
            end=1)  # sleep a random duration to try and evade suspention

        res = self._fetch(f"/me")

        data = res.json()

        return data

    def get_invitations(self, start=0, limit=3):
        """
        Return list of new invites
        """
        params = {
            "start": start,
            "count": limit,
            "includeInsights": True,
            "q": "receivedInvitation"
        }

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/relationships/invitationViews",
            params=params)

        if res.status_code != 200:
            return []

        response_payload = res.json()
        return [
            element["invitation"] for element in response_payload["elements"]
        ]

    def reply_invitation(self,
                         invitation_entity_urn,
                         invitation_shared_secret,
                         action="accept"):
        """
        Reply to an invite, the default is to accept the invitation.
        @Param: invitation_entity_urn: str
        @Param: invitation_shared_secret: str
        @Param: action: "accept" or "ignore"
        Returns True if sucess, False otherwise
        """
        invitation_id = get_id_from_urn(invitation_entity_urn)
        params = {'action': action}
        payload = json.dumps({
            "invitationId": invitation_id,
            "invitationSharedSecret": invitation_shared_secret,
            "isGenericInvitation": False
        })

        res = self.client.session.post(
            f"{self.client.API_BASE_URL}/relationships/invitations/{invitation_id}",
            params=params,
            data=payload)

        return res.status_code == 200

    def add_connection(self, profile_urn_id=None, message=None):
        data = '{"trackingId":"yvzykVorToqcOuvtxjSFMg==","invitations":[],"excludeInvitations":[],"invitee":{"com.linkedin.voyager.growth.invitation.InviteeProfile":{"profileId":' + \
            '"' + profile_urn_id + '"' + '}}}'
        if message is not None:
            data = '{"trackingId":"yvzykVorToqcOuvtxjSFMg==","invitations":[],"excludeInvitations":[],"invitee":{"com.linkedin.voyager.growth.invitation.InviteeProfile":{"profileId":' + \
                '"' + profile_urn_id + '"' + '}},"message":' '"' + message + '"' + '}'

        res = self._post(
            '/growth/normInvitations',
            data=data,
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        return res.status_code

    def remove_connection(self, public_profile_id):
        res = self._post(
            f"/identity/profiles/{public_profile_id}/profileActions?action=disconnect",
        )

        return res.status_code != 200

    def get_sent_invintations(self, start=0):
        res = self._fetch(
            f"/relationships/sentInvitationViewsV2?count=100&invitationType=CONNECTION&q=invitationType&start="
            + str(start),
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"})

        data = res.json()

        return data

    def get_invitation_entity_urn(self, profile_urn=''):
        sent_invitations = self.get_sent_invintations().get('included', {})
        for sent_invite in sent_invitations:
            if sent_invite.get('toMemberId', '') == profile_urn:
                return sent_invite.get('entityUrn', '')

    def withdraw_invitation(self, entity_urn=''):

        payload = {
            "entityUrn": entity_urn,
            "genericInvitation": False,
            "genericInvitationType": "CONNECTION",
            "inviteActionType": "ACTOR_WITHDRAW",
        }

        res = self._post(f"/relationships/invitations?action=closeInvitations",
                         data=json.dumps(payload))

        return res.status_code == 200

    def get_typehead(self, keywords=None, type=None):
        res = self._fetch(
            f'/typeahead/hitsV2?keywords=' + keywords +
            '&origin=OTHER&q=type&type=' + type,
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"})

        data = res.json()

        elements = data.get("data").get("elements")

        return elements
Beispiel #3
0
class Linkedin(object):
    """
    Class for accessing Linkedin API.
    """

    _MAX_UPDATE_COUNT = 100  # max seems to be 100
    _MAX_SEARCH_COUNT = 49  # max seems to be 49, and min seems to be 2
    _MAX_REPEATED_REQUESTS = (
        200  # VERY conservative max requests count to avoid rate-limit
    )

    def __init__(
        self,
        username,
        password,
        *,
        authenticate=True,
        refresh_cookies=False,
        debug=False,
        proxies={},
    ):
        self.client = Client(
            refresh_cookies=refresh_cookies, debug=debug, proxies=proxies
        )
        logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
        self.logger = logger

        if authenticate:
            self.client.authenticate(username, password)

    def _fetch(self, uri, evade=default_evade, **kwargs):
        """
        GET request to Linkedin API
        """
        evade()

        url = f"{self.client.API_BASE_URL}{uri}"
        return self.client.session.get(url, **kwargs)

    def _post(self, uri, evade=default_evade, **kwargs):
        """
        POST request to Linkedin API
        """
        evade()

        url = f"{self.client.API_BASE_URL}{uri}"
        return self.client.session.post(url, **kwargs)

    def search(self, params, limit=None):
        """
        Do a search.
        """
        if not limit or limit > Linkedin._MAX_SEARCH_COUNT:
            limit = Linkedin._MAX_SEARCH_COUNT
        count = limit

        results = []
        while True:
            # when we're close to the limit, only fetch what we need to
            if limit - len(results) < count:
                count = limit - len(results)
            default_params = {
                "count": str(count),
                "filters": "List()",
                "origin": "GLOBAL_SEARCH_HEADER",
                "q": "all",
                "start": len(results),
                "queryContext": "List(spellCorrectionEnabled->true,relatedSearchesEnabled->true,kcardTypes->PROFILE|COMPANY)",
            }
            default_params.update(params)

            res = self._fetch(
                f"/search/blended?{urlencode(default_params, safe='(),')}",
                headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
            )
            data = res.json()

            new_elements = []
            for i in range(len(data["data"]["elements"])):
                new_elements.extend(data["data"]["elements"][i]["elements"])
                # not entirely sure what extendedElements generally refers to - keyword search gives back a single job?
                # new_elements.extend(data["data"]["elements"][i]["extendedElements"])
            results.extend(new_elements)

            # break the loop if we're done searching
            # NOTE: we could also check for the `total` returned in the response.
            # This is in data["data"]["paging"]["total"]
            if (
                len(results) >= limit  # if our results exceed set limit
                or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS
            ) or len(new_elements) == 0:
                break

            self.logger.debug(f"results grew to {len(results)}")

        return results

    def search_people(
        self,
        keywords=None,
        connection_of=None,
        network_depth=None,
        current_company=None,
        past_companies=None,
        nonprofit_interests=None,
        profile_languages=None,
        regions=None,
        industries=None,
        schools=None,
        title=None,  # `keyword_title` and `title` are the same. We kept `title` for backward compatibility. Please only use one of them.
        include_private_profiles=False,  # profiles without a public id, "Linkedin Member"
        limit=None,
        # Keywords filter
        keyword_first_name=None,
        keyword_last_name=None,
        keyword_title=None,  # `keyword_title` and `title` are the same. We kept `title` for backward compatibility. Please only use one of them.
        keyword_company=None,
        keyword_school=None,
    ):
        """
        Do a people search.
        """
        filters = ["resultType->PEOPLE"]
        if connection_of:
            filters.append(f"connectionOf->{connection_of}")
        if network_depth:
            filters.append(f"network->{network_depth}")
        if regions:
            filters.append(f'geoRegion->{"|".join(regions)}')
        if industries:
            filters.append(f'industry->{"|".join(industries)}')
        if current_company:
            filters.append(f'currentCompany->{"|".join(current_company)}')
        if past_companies:
            filters.append(f'pastCompany->{"|".join(past_companies)}')
        if profile_languages:
            filters.append(f'profileLanguage->{"|".join(profile_languages)}')
        if nonprofit_interests:
            filters.append(f'nonprofitInterest->{"|".join(nonprofit_interests)}')
        if schools:
            filters.append(f'schools->{"|".join(schools)}')
        # `Keywords` filter
        keyword_title = keyword_title if keyword_title else title
        if keyword_first_name:
            filters.append(f"firstName->{keyword_first_name}")
        if keyword_last_name:
            filters.append(f"lastName->{keyword_last_name}")
        if keyword_title:
            filters.append(f"title->{keyword_title}")
        if keyword_company:
            filters.append(f"company->{keyword_company}")
        if keyword_school:
            filters.append(f"school->{keyword_school}")

        params = {"filters": "List({})".format(",".join(filters))}

        if keywords:
            params["keywords"] = keywords

        data = self.search(params, limit=limit)

        results = []
        for item in data:
            if "publicIdentifier" not in item:
                continue
            results.append(
                {
                    "urn_id": get_id_from_urn(item.get("targetUrn")),
                    "distance": item.get("memberDistance", {}).get("value"),
                    "public_id": item.get("publicIdentifier"),
                }
            )

        return results

    def search_companies(self, keywords=None, limit=None):
        """
        Do a company search.
        """
        filters = ["resultType->COMPANIES"]

        params = {
            "filters": "List({})".format(",".join(filters)),
            "queryContext": "List(spellCorrectionEnabled->true)",
        }

        if keywords:
            params["keywords"] = keywords

        data = self.search(params, limit=limit)

        results = []
        for item in data:
            if item.get("type") != "COMPANY":
                continue
            results.append(
                {
                    "urn": item.get("targetUrn"),
                    "urn_id": get_id_from_urn(item.get("targetUrn")),
                    "name": item.get("title", {}).get("text"),
                    "headline": item.get("headline", {}).get("text"),
                    "subline": item.get("subline", {}).get("text"),
                }
            )

        return results

    def search_jobs(self, keywords, location, count=25, start=0, listed_at=86400):
        """
        Do a job search.

        [keywords] - any queries using OR, AND, (), ""
        [location] - job location
        [count] - number of jobs returned
        [start] - for paging to fetch the next set of results
        [sort_by] - sort by relevance "List(R)" or by most recent "List(DD)"
        [posted_at] - limits the results based on date posted, in seconds
        
        """
        params = {
            "decorationId": "com.linkedin.voyager.deco.jserp.WebJobSearchHit-22",
            "location": location,
            "origin": "JOB_SEARCH_RESULTS_PAGE",
            "start": start,
            "q": "jserpAll",
            "query": "search",
            "sortBy": "List(DD)",
        }

        # count must be below 50
        if count > 49:
            count = 49
        params["count"] = count

        # check if input is int
        if isinstance(listed_at, int):
            params["f_TPR"] = f"List(r{listed_at})"
        else:
            params["f_TPR"] = "List(r86400)"
        str_params = urlencode(params, safe="(),")

        # we need to encode the keywords incase it used brackets, otherwise it will return an error
        if keywords:
            keywords_encoded = f"&keywords={quote(keywords)}"
            str_params += keywords_encoded
        res = self._fetch(
            f"/search/hits?{str_params}",
            headers={
                "accept": "application/vnd.linkedin.normalized+json+2.1",
                "x-li-track": '{"clientVersion":"1.6.*","osName":"web","timezoneOffset":1,"deviceFormFactor":"DESKTOP","mpName":"voyager-web","displayDensity":1.100000023841858}',
            },
        )

        data = res.json()
        return data

    def get_profile_contact_info(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        res = self._fetch(
            f"/identity/profiles/{public_id or urn_id}/profileContactInfo"
        )
        data = res.json()

        contact_info = {
            "email_address": data.get("emailAddress"),
            "websites": [],
            "twitter": data.get("twitterHandles"),
            "birthdate": data.get("birthDateOn"),
            "ims": data.get("ims"),
            "phone_numbers": data.get("phoneNumbers", []),
        }

        websites = data.get("websites", [])
        for item in websites:
            if "com.linkedin.voyager.identity.profile.StandardWebsite" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.StandardWebsite"
                ]["category"]
            elif "" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.CustomWebsite"
                ]["label"]

            del item["type"]

        contact_info["websites"] = websites

        return contact_info

    def get_profile_skills(self, public_id=None, urn_id=None):
        """
        Return the skills of a profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        params = {"count": 100, "start": 0}
        res = self._fetch(
            f"/identity/profiles/{public_id or urn_id}/skills", params=params
        )
        data = res.json()

        skills = data.get("elements", [])
        for item in skills:
            del item["entityUrn"]

        return skills

    def get_profile(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        # NOTE this still works for now, but will probably eventually have to be converted to
        # https://www.linkedin.com/voyager/api/identity/profiles/ACoAAAKT9JQBsH7LwKaE9Myay9WcX8OVGuDq9Uw
        res = self._fetch(f"/identity/profiles/{public_id or urn_id}/profileView")

        data = res.json()
        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        # massage [profile] data
        profile = data["profile"]
        if "miniProfile" in profile:
            if "picture" in profile["miniProfile"]:
                profile["displayPictureUrl"] = profile["miniProfile"]["picture"][
                    "com.linkedin.common.VectorImage"
                ]["rootUrl"]
            profile["profile_id"] = get_id_from_urn(profile["miniProfile"]["entityUrn"])
            profile["profile_urn"] = profile["miniProfile"]["entityUrn"]

            del profile["miniProfile"]

        del profile["defaultLocale"]
        del profile["supportedLocales"]
        del profile["versionTag"]
        del profile["showEducationOnProfileTopCard"]

        # massage [experience] data
        experience = data["positionView"]["elements"]
        for item in experience:
            if "company" in item and "miniCompany" in item["company"]:
                if "logo" in item["company"]["miniCompany"]:
                    logo = item["company"]["miniCompany"]["logo"].get(
                        "com.linkedin.common.VectorImage"
                    )
                    if logo:
                        item["companyLogoUrl"] = logo["rootUrl"]
                del item["company"]["miniCompany"]

        profile["experience"] = experience

        # massage [skills] data
        # skills = [item["name"] for item in data["skillView"]["elements"]]
        # profile["skills"] = skills

        profile["skills"] = self.get_profile_skills(public_id=public_id, urn_id=urn_id)

        # massage [education] data
        education = data["educationView"]["elements"]
        for item in education:
            if "school" in item:
                if "logo" in item["school"]:
                    item["school"]["logoUrl"] = item["school"]["logo"][
                        "com.linkedin.common.VectorImage"
                    ]["rootUrl"]
                    del item["school"]["logo"]

        profile["education"] = education

        # massage [languages] data
        languages = data["languageView"]["elements"]
        for item in languages:
            del item["entityUrn"]
        profile["languages"] = languages

        # massage [publications] data
        publications = data["publicationView"]["elements"]
        for item in publications:
            del item["entityUrn"]
            for author in item.get("authors", []):
                del author["entityUrn"]
        profile["publications"] = publications

        # massage [certifications] data
        certifications = data["certificationView"]["elements"]
        for item in certifications:
            del item["entityUrn"]
        profile["certifications"] = certifications

        # massage [volunteer] data
        volunteer = data["volunteerExperienceView"]["elements"]
        for item in volunteer:
            del item["entityUrn"]
        profile["volunteer"] = volunteer

        # massage [honors] data
        honors = data["honorView"]["elements"]
        for item in honors:
            del item["entityUrn"]
        profile["honors"] = honors

        return profile

    def get_profile_connections(self, urn_id=None):
        """
        Return a list of profile ids connected to profile of given [urn_id].
        If urn_id is None, then the currently logged in user's connections are returned
        """
        return self.search_people(connection_of=urn_id, network_depth="F")

    def get_company_updates(
        self, public_id=None, urn_id=None, max_results=None, results=[]
    ):
        """"
        Return a list of company posts

        [public_id] - public identifier ie - microsoft
        [urn_id] - id provided by the related URN
        """
        params = {
            "companyUniversalName": {public_id or urn_id},
            "q": "companyFeedByUniversalName",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch(f"/feed/updates", params=params)

        data = res.json()

        if (
            len(data["elements"]) == 0
            or (max_results is not None and len(results) >= max_results)
            or (
                max_results is not None
                and len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS
            )
        ):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_company_updates(
            public_id=public_id, urn_id=urn_id, results=results, max_results=max_results
        )

    def get_profile_updates(
        self, public_id=None, urn_id=None, max_results=None, results=[]
    ):
        """"
        Return a list of profile posts

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        params = {
            "profileId": {public_id or urn_id},
            "q": "memberShareFeed",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch(f"/feed/updates", params=params)

        data = res.json()

        if (
            len(data["elements"]) == 0
            or (max_results is not None and len(results) >= max_results)
            or (
                max_results is not None
                and len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS
            )
        ):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_profile_updates(
            public_id=public_id, urn_id=urn_id, results=results, max_results=max_results
        )

    def get_current_profile_views(self):
        """
        Get profile view statistics, including chart data.
        """
        res = self._fetch(f"/identity/wvmpCards")

        data = res.json()

        return data["elements"][0]["value"][
            "com.linkedin.voyager.identity.me.wvmpOverview.WvmpViewersCard"
        ]["insightCards"][0]["value"][
            "com.linkedin.voyager.identity.me.wvmpOverview.WvmpSummaryInsightCard"
        ][
            "numViews"
        ]

    def get_school(self, public_id):
        """
        Return data for a single school.

        [public_id] - public identifier i.e. uq
        """
        params = {
            "decorationId": "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch(f"/organization/companies?{urlencode(params)}")

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data))
            return {}

        school = data["elements"][0]

        return school

    def get_company(self, public_id):
        """
        Return data for a single company.

        [public_id] - public identifier i.e. univeristy-of-queensland
        """
        params = {
            "decorationId": "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch(f"/organization/companies", params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        company = data["elements"][0]

        return company

    def get_conversation_details(self, profile_urn_id):
        """
        Return the conversation (or "message thread") details for a given [public_profile_id]
        """
        # passing `params` doesn't work properly, think it's to do with List().
        # Might be a bug in `requests`?
        res = self._fetch(
            f"/messaging/conversations?\
            keyVersion=LEGACY_INBOX&q=participants&recipients=List({profile_urn_id})"
        )

        data = res.json()

        item = data["elements"][0]
        item["id"] = get_id_from_urn(item["entityUrn"])

        return item

    def get_conversations(self):
        """
        Return list of conversations the user is in.
        """
        params = {"keyVersion": "LEGACY_INBOX"}

        res = self._fetch(f"/messaging/conversations", params=params)

        return res.json()

    def get_conversation(self, conversation_urn_id):
        """
        Return the full conversation at a given [conversation_urn_id]
        """
        res = self._fetch(f"/messaging/conversations/{conversation_urn_id}/events")

        return res.json()

    def send_message(self, conversation_urn_id=None, recipients=[], message_body=None):
        """
        Send a message to a given conversation. If error, return true.

        Recipients: List of profile urn id's
        """
        params = {"action": "create"}

        if not (conversation_urn_id or recipients) and not message_body:
            return True

        message_event = {
            "eventCreate": {
                "value": {
                    "com.linkedin.voyager.messaging.create.MessageCreate": {
                        "body": message_body,
                        "attachments": [],
                        "attributedBody": {"text": message_body, "attributes": []},
                        "mediaAttachments": [],
                    }
                }
            }
        }

        if conversation_urn_id and not recipients:
            res = self._post(
                f"/messaging/conversations/{conversation_urn_id}/events",
                params=params,
                data=json.dumps(message_event),
            )
        elif recipients and not conversation_urn_id:
            message_event["recipients"] = recipients
            message_event["subtype"] = "MEMBER_TO_MEMBER"
            payload = {
                "keyVersion": "LEGACY_INBOX",
                "conversationCreate": message_event,
            }
            res = self._post(
                f"/messaging/conversations", params=params, data=json.dumps(payload)
            )

        return res.status_code != 201

    def mark_conversation_as_seen(self, conversation_urn_id):
        """
        Send seen to a given conversation. If error, return True.
        """
        payload = json.dumps({"patch": {"$set": {"read": True}}})

        res = self._post(
            f"/messaging/conversations/{conversation_urn_id}", data=payload
        )

        return res.status_code != 200

    def get_user_profile(self):
        """"
        Return current user profile
        """
        sleep(
            random.randint(0, 1)
        )  # sleep a random duration to try and evade suspention

        res = self._fetch(f"/me")

        data = res.json()

        return data

    def get_invitations(self, start=0, limit=3):
        """
        Return list of new invites
        """
        params = {
            "start": start,
            "count": limit,
            "includeInsights": True,
            "q": "receivedInvitation",
        }

        res = self._fetch(
            f"{self.client.API_BASE_URL}/relationships/invitationViews", params=params
        )

        if res.status_code != 200:
            return []

        response_payload = res.json()
        return [element["invitation"] for element in response_payload["elements"]]

    def reply_invitation(
        self, invitation_entity_urn, invitation_shared_secret, action="accept"
    ):
        """
        Reply to an invite, the default is to accept the invitation.
        @Param: invitation_entity_urn: str
        @Param: invitation_shared_secret: str
        @Param: action: "accept" or "ignore"
        Returns True if sucess, False otherwise
        """
        invitation_id = get_id_from_urn(invitation_entity_urn)
        params = {"action": action}
        payload = json.dumps(
            {
                "invitationId": invitation_id,
                "invitationSharedSecret": invitation_shared_secret,
                "isGenericInvitation": False,
            }
        )

        res = self._post(
            f"{self.client.API_BASE_URL}/relationships/invitations/{invitation_id}",
            params=params,
            data=payload,
        )

        return res.status_code == 200

    # def add_connection(self, profile_urn_id):
    #     payload = {
    #         "emberEntityName": "growth/invitation/norm-invitation",
    #         "invitee": {
    #             "com.linkedin.voyager.growth.invitation.InviteeProfile": {
    #                 "profileId": profile_urn_id
    #             }
    #         },
    #     }

    #     print(payload)

    #     res = self._post(
    #         "/growth/normInvitations",
    #         data=payload,
    #         headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
    #     )

    #     return res.status_code != 201

    def remove_connection(self, public_profile_id):
        res = self._post(
            f"/identity/profiles/{public_profile_id}/profileActions?action=disconnect",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        return res.status_code != 200

    # TODO doesn't work
    # def view_profile(self, public_profile_id):
    #     res = self._fetch(
    #         f"/identity/profiles/{public_profile_id}/profileView",
    #         headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
    #     )

    #     return res.status_code != 200

    def get_profile_privacy_settings(self, public_profile_id):
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/privacySettings",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})

    def get_profile_member_badges(self, public_profile_id):
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/memberBadges",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})

    def get_profile_network_info(self, public_profile_id):
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/networkinfo",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})

    def unfollow_entity(self, urn):
        payload = {"urn": f"urn:li:fs_followingInfo:{urn}"}
        res = self._post(
            "/feed/follows?action=unfollowByEntityUrn",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
            data=json.dumps(payload),
        )

        err = False
        if res.status_code != 200:
            err = True

        return err
Beispiel #4
0
class Linkedin(object):
    """
    Class for accessing Linkedin API.
    """

    _MAX_UPDATE_COUNT = 100  # max seems to be 100
    _MAX_SEARCH_COUNT = 49  # max seems to be 49
    _MAX_REPEATED_REQUESTS = (
        200)  # VERY conservative max requests count to avoid rate-limit

    def __init__(self, username, password, refresh_cookies=False, debug=False):
        self.client = Client(refresh_cookies=refresh_cookies, debug=debug)
        self.client.authenticate(username, password)
        logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)

        self.logger = logger

    def _fetch(self, uri, evade=default_evade, **kwargs):
        """
        GET request to Linkedin API
        """
        evade()

        url = "{}{}".format(self.client.API_BASE_URL, uri)
        return self.client.session.get(url, **kwargs)

    def _post(self, uri, evade=default_evade, **kwargs):
        """
        POST request to Linkedin API
        """
        evade()

        url = "{}{}".format(self.client.API_BASE_URL, uri)
        return self.client.session.post(url, **kwargs)

    def search(self, params, limit=None, results=[]):
        """
        Do a search.
        """
        count = (limit if limit and limit <= Linkedin._MAX_SEARCH_COUNT else
                 Linkedin._MAX_SEARCH_COUNT)
        default_params = {
            "count":
            str(count),
            "filters":
            "List()",
            "origin":
            "GLOBAL_SEARCH_HEADER",
            "q":
            "all",
            "start":
            len(results),
            "queryContext":
            "List(spellCorrectionEnabled->true,relatedSearchesEnabled->true,kcardTypes->PROFILE|COMPANY)",
        }

        default_params.update(params)

        res = self._fetch(
            "/search/blended?{}".format(urlencode(default_params)),
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        data = res.json()

        new_elements = []
        for i in range(len(data["data"]["elements"])):
            new_elements.extend(data["data"]["elements"][i]["elements"])
            # not entirely sure what extendedElements generally refers to - keyword search gives back a single job?
            # new_elements.extend(data["data"]["elements"][i]["extendedElements"])

        results.extend(new_elements)
        results = results[:
                          limit]  # always trim results, no matter what the request returns

        # recursive base case
        if (limit is not None and
            (len(results) >= limit  # if our results exceed set limit
             or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS)
            ) or len(new_elements) == 0:
            return results

        self.logger.debug("results grew to {}".format(len(results)))

        return self.search(params, results=results, limit=limit)

    def search_people(
        self,
        keywords=None,
        connection_of=None,
        network_depth=None,
        current_company=None,
        past_companies=None,
        nonprofit_interests=None,
        profile_languages=None,
        regions=None,
        industries=None,
        schools=None,
        include_private_profiles=False,  # profiles without a public id, "Linkedin Member"
        limit=None,
    ):
        """
        Do a people search.
        """
        filters = ["resultType->PEOPLE"]
        if connection_of:
            filters.append("connectionOf->{}".format(connection_of))
        if network_depth:
            filters.append("network->{}".format(network_depth))
        if regions:
            filters.append('geoRegion->{}'.format("|".join(regions)))
        if industries:
            filters.append('industry->{}'.format("|".join(industries)))
        if current_company:
            filters.append('currentCompany->{}'.format(
                "|".join(current_company)))
        if past_companies:
            filters.append('pastCompany->{}'.format("|".join(past_companies)))
        if profile_languages:
            filters.append('profileLanguage->{}'.format(
                "|".join(profile_languages)))
        if nonprofit_interests:
            filters.append('nonprofitInterest->{}'.format(
                "|".join(nonprofit_interests)))
        if schools:
            filters.append('schools->{}'.format("|".join(schools)))

        params = {"filters": "List({})".format(",".join(filters))}

        if keywords:
            params["keywords"] = keywords

        data = self.search(params, limit=limit)

        results = []
        for item in data:
            if "publicIdentifier" not in item:
                continue
            results.append({
                "urn_id":
                get_id_from_urn(item.get("targetUrn")),
                "distance":
                item.get("memberDistance", {}).get("value"),
                "public_id":
                item.get("publicIdentifier"),
            })

        return results

    def get_profile_contact_info(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        res = self._fetch("/identity/profiles/{}/profileContactInfo".format(
            public_id or urn_id))
        data = res.json()

        contact_info = {
            "email_address": data.get("emailAddress"),
            "websites": [],
            "twitter": data.get("twitterHandles"),
            "birthdate": data.get("birthDateOn"),
            "ims": data.get("ims"),
            "phone_numbers": data.get("phoneNumbers", []),
        }

        websites = data.get("websites", [])
        for item in websites:
            if "com.linkedin.voyager.identity.profile.StandardWebsite" in item[
                    "type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.StandardWebsite"][
                        "category"]
            elif "" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.CustomWebsite"][
                        "label"]

            del item["type"]

        contact_info["websites"] = websites

        return contact_info

    def get_profile_skills(self, public_id=None, urn_id=None):
        """
        Return the skills of a profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        params = {"count": 100, "start": 0}
        res = self._fetch("/identity/profiles/{}/skills".format(public_id
                                                                or urn_id),
                          params=params)
        data = res.json()

        skills = data.get("elements", [])
        for item in skills:
            del item["entityUrn"]

        return skills

    def get_profile(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        res = self._fetch("/identity/profiles/{}/profileView".format(
            public_id or urn_id))

        data = res.json()
        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        # massage [profile] data
        profile = data["profile"]
        if "miniProfile" in profile:
            if "picture" in profile["miniProfile"]:
                profile["displayPictureUrl"] = profile["miniProfile"][
                    "picture"]["com.linkedin.common.VectorImage"]["rootUrl"]
            profile["profile_id"] = get_id_from_urn(
                profile["miniProfile"]["entityUrn"])

            del profile["miniProfile"]

        del profile["defaultLocale"]
        del profile["supportedLocales"]
        del profile["versionTag"]
        del profile["showEducationOnProfileTopCard"]

        # massage [experience] data
        experience = data["positionView"]["elements"]
        for item in experience:
            if "company" in item and "miniCompany" in item["company"]:
                if "logo" in item["company"]["miniCompany"]:
                    logo = item["company"]["miniCompany"]["logo"].get(
                        "com.linkedin.common.VectorImage")
                    if logo:
                        item["companyLogoUrl"] = logo["rootUrl"]
                del item["company"]["miniCompany"]

        profile["experience"] = experience

        # massage [skills] data
        # skills = [item["name"] for item in data["skillView"]["elements"]]
        # profile["skills"] = skills

        profile["skills"] = self.get_profile_skills(public_id=public_id,
                                                    urn_id=urn_id)

        # massage [education] data
        education = data["educationView"]["elements"]
        for item in education:
            if "school" in item:
                if "logo" in item["school"]:
                    item["school"]["logoUrl"] = item["school"]["logo"][
                        "com.linkedin.common.VectorImage"]["rootUrl"]
                    del item["school"]["logo"]

        profile["education"] = education

        return profile

    def get_profile_connections(self, urn_id):
        """
        Return a list of profile ids connected to profile of given [urn_id]
        """
        return self.search_people(connection_of=urn_id, network_depth="F")

    def get_company_updates(self,
                            public_id=None,
                            urn_id=None,
                            max_results=None,
                            results=[]):
        """"
        Return a list of company posts

        [public_id] - public identifier ie - microsoft
        [urn_id] - id provided by the related URN
        """
        params = {
            "companyUniversalName": {public_id or urn_id},
            "q": "companyFeedByUniversalName",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch("/feed/updates", params=params)

        data = res.json()

        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results) or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"])
        self.logger.debug("results grew: {}".format(len(results)))

        return self.get_company_updates(public_id=public_id,
                                        urn_id=urn_id,
                                        results=results,
                                        max_results=max_results)

    def get_profile_updates(self,
                            public_id=None,
                            urn_id=None,
                            max_results=None,
                            results=[]):
        """"
        Return a list of profile posts

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        params = {
            "profileId": {public_id or urn_id},
            "q": "memberShareFeed",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch("/feed/updates", params=params)

        data = res.json()

        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results) or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"])
        self.logger.debug("results grew: {}".format(len(results)))

        return self.get_profile_updates(public_id=public_id,
                                        urn_id=urn_id,
                                        results=results,
                                        max_results=max_results)

    def get_current_profile_views(self):
        """
        Get profile view statistics, including chart data.
        """
        res = self._fetch("/identity/wvmpCards")

        data = res.json()

        return data["elements"][0]["value"][
            "com.linkedin.voyager.identity.me.wvmpOverview.WvmpViewersCard"][
                "insightCards"][0]["value"][
                    "com.linkedin.voyager.identity.me.wvmpOverview.WvmpSummaryInsightCard"][
                        "numViews"]

    def get_school(self, public_id):
        """
        Return data for a single school.

        [public_id] - public identifier i.e. uq
        """
        params = {
            "decorationId":
            "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch("/organization/companies?{}".format(
            urlencode(params)))

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data))
            return {}

        school = data["elements"][0]

        return school

    def get_company(self, public_id):
        """
        Return data for a single company.

        [public_id] - public identifier i.e. univeristy-of-queensland
        """
        params = {
            "decorationId":
            "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch("/organization/companies", params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        company = data["elements"][0]

        return company

    def get_conversation_details(self, profile_urn_id):
        """
        Return the conversation (or "message thread") details for a given [public_profile_id]
        """
        # passing `params` doesn't work properly, think it's to do with List().
        # Might be a bug in `requests`?
        res = self._fetch("/messaging/conversations?\
            keyVersion=LEGACY_INBOX&q=participants&recipients=List({})".format(
            profile_urn_id))

        data = res.json()

        item = data["elements"][0]
        item["id"] = get_id_from_urn(item["entityUrn"])

        return item

    def get_conversations(self):
        """
        Return list of conversations the user is in.
        """
        params = {"keyVersion": "LEGACY_INBOX"}

        res = self._fetch("/messaging/conversations", params=params)

        return res.json()

    def get_conversation(self, conversation_urn_id):
        """
        Return the full conversation at a given [conversation_urn_id]
        """
        res = self._fetch(
            "/messaging/conversations/{}/events".format(conversation_urn_id))

        return res.json()

    def send_message(self,
                     conversation_urn_id=None,
                     recipients=[],
                     message_body=None):
        """
        Send a message to a given conversation. If error, return true.

        Recipients: List of profile urn id's
        """
        params = {"action": "create"}

        if not (conversation_urn_id or recipients) and not message_body:
            return True

        message_event = {
            "eventCreate": {
                "value": {
                    "com.linkedin.voyager.messaging.create.MessageCreate": {
                        "body": message_body,
                        "attachments": [],
                        "attributedBody": {
                            "text": message_body,
                            "attributes": []
                        },
                        "mediaAttachments": [],
                    }
                }
            }
        }

        if conversation_urn_id and not recipients:
            res = self._post(
                "/messaging/conversations/{}/events".format(
                    conversation_urn_id),
                params=params,
                data=json.dumps(message_event),
            )
        elif recipients and not conversation_urn_id:
            message_event["recipients"] = recipients
            message_event["subtype"] = "MEMBER_TO_MEMBER"
            payload = {
                "keyVersion": "LEGACY_INBOX",
                "conversationCreate": message_event,
            }
            res = self._post("/messaging/conversations",
                             params=params,
                             data=json.dumps(payload))

        return res.status_code != 201

    def mark_conversation_as_seen(self, conversation_urn_id):
        """
        Send seen to a given conversation. If error, return True.
        """
        payload = json.dumps({"patch": {"$set": {"read": True}}})

        res = self._post(
            "/messaging/conversations/{}".format(conversation_urn_id),
            data=payload)

        return res.status_code != 200

    def get_user_profile(self):
        """"
        Return current user profile
        """
        sleep(random.randint(
            0, 1))  # sleep a random duration to try and evade suspention

        res = self._fetch("/me")

        data = res.json()

        return data

    def get_invitations(self, start=0, limit=3):
        """
        Return list of new invites
        """
        params = {
            "start": start,
            "count": limit,
            "includeInsights": True,
            "q": "receivedInvitation"
        }

        res = self.client.session.get(
            "{}/relationships/invitationViews".format(
                self.client.API_BASE_URL),
            params=params)

        if res.status_code != 200:
            return []

        response_payload = res.json()
        return [
            element["invitation"] for element in response_payload["elements"]
        ]

    def reply_invitation(self,
                         invitation_entity_urn,
                         invitation_shared_secret,
                         action="accept"):
        """
        Reply to an invite, the default is to accept the invitation.
        @Param: invitation_entity_urn: str
        @Param: invitation_shared_secret: str
        @Param: action: "accept" or "ignore"
        Returns True if sucess, False otherwise
        """
        invitation_id = get_id_from_urn(invitation_entity_urn)
        params = {'action': action}
        payload = json.dumps({
            "invitationId": invitation_id,
            "invitationSharedSecret": invitation_shared_secret,
            "isGenericInvitation": False
        })

        res = self.client.session.post(
            "{}/relationships/invitations/{invitation_id}".format(
                self.client.API_BASE_URL),
            params=params,
            data=payload)

        return res.status_code == 200

    # def add_connection(self, profile_urn_id):
    #     payload = {
    #         "emberEntityName": "growth/invitation/norm-invitation",
    #         "invitee": {
    #             "com.linkedin.voyager.growth.invitation.InviteeProfile": {
    #                 "profileId": profile_urn_id
    #             }
    #         },
    #     }

    #     print(payload)

    #     res = self._post(
    #         "/growth/normInvitations",
    #         data=payload,
    #         headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
    #     )

    #     return res.status_code != 201

    def remove_connection(self, public_profile_id):
        res = self._post(
            "/identity/profiles/{}/profileActions?action=disconnect".format(
                public_profile_id),
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        return res.status_code != 200
Beispiel #5
0
class Linkedin(object):
    """
    Class for accessing the LinkedIn API.

    :param username: Username of LinkedIn account.
    :type username: str
    :param password: Password of LinkedIn account.
    :type password: str
    """

    _MAX_UPDATE_COUNT = 100  # max seems to be 100
    _MAX_SEARCH_COUNT = 49  # max seems to be 49, and min seems to be 2
    _MAX_REPEATED_REQUESTS = (
        50  # VERY conservative max requests count to avoid rate-limit
    )

    def __init__(
        self,
        username,
        password,
        *,
        authenticate=True,
        refresh_cookies=False,
        debug=False,
        proxies={},
        cookies=None,
        cookies_dir=None,
    ):
        """Constructor method"""
        self.client = Client(
            refresh_cookies=refresh_cookies,
            debug=debug,
            proxies=proxies,
            cookies_dir=cookies_dir,
        )
        logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
        self.logger = logger

        if authenticate:
            if cookies:
                # If the cookies are expired, the API won't work anymore since
                # `username` and `password` are not used at all in this case.
                self.client._set_session_cookies(cookies)
            else:
                self.client.authenticate(username, password)

    def _fetch(self, uri, evade=default_evade, base_request=False, **kwargs):
        """GET request to Linkedin API"""
        evade()

        url = f"{self.client.API_BASE_URL if not base_request else self.client.LINKEDIN_BASE_URL}{uri}"
        return self.client.session.get(url, **kwargs)

    def _post(self, uri, evade=default_evade, base_request=False, **kwargs):
        """POST request to Linkedin API"""
        evade()

        url = f"{self.client.API_BASE_URL if not base_request else self.client.LINKEDIN_BASE_URL}{uri}"
        return self.client.session.post(url, **kwargs)

    def search(self, params, limit=-1, offset=0):
        """Perform a LinkedIn search.

        :param params: Search parameters (see code)
        :type params: dict
        :param limit: Maximum length of the returned list, defaults to -1 (no limit)
        :type limit: int, optional
        :param offset: Index to start searching from
        :type offset: int, optional


        :return: List of search results
        :rtype: list
        """
        count = Linkedin._MAX_SEARCH_COUNT
        if limit is None:
            limit = -1

        results = []
        while True:
            # when we're close to the limit, only fetch what we need to
            if limit > -1 and limit - len(results) < count:
                count = limit - len(results)
            default_params = {
                "count":
                str(count),
                "filters":
                "List()",
                "origin":
                "GLOBAL_SEARCH_HEADER",
                "q":
                "all",
                "start":
                len(results) + offset,
                "queryContext":
                "List(spellCorrectionEnabled->true,relatedSearchesEnabled->true,kcardTypes->PROFILE|COMPANY)",
            }
            default_params.update(params)

            res = self._fetch(
                f"/search/blended?{urlencode(default_params, safe='(),')}",
                headers={
                    "accept": "application/vnd.linkedin.normalized+json+2.1"
                },
            )
            data = res.json()

            new_elements = []
            elements = data.get("data", {}).get("elements", [])
            for i in range(len(elements)):
                new_elements.extend(elements[i]["elements"])
                # not entirely sure what extendedElements generally refers to - keyword search gives back a single job?
                # new_elements.extend(data["data"]["elements"][i]["extendedElements"])
            results.extend(new_elements)

            # break the loop if we're done searching
            # NOTE: we could also check for the `total` returned in the response.
            # This is in data["data"]["paging"]["total"]
            if ((limit > -1 and len(results) >= limit
                 )  # if our results exceed set limit
                    or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS
                ) or len(new_elements) == 0:
                break

            self.logger.debug(f"results grew to {len(results)}")

        return results

    def search_people(
        self,
        keywords=None,
        connection_of=None,
        network_depths=None,
        current_company=None,
        past_companies=None,
        nonprofit_interests=None,
        profile_languages=None,
        regions=None,
        industries=None,
        schools=None,
        contact_interests=None,
        service_categories=None,
        include_private_profiles=False,  # profiles without a public id, "Linkedin Member"
        # Keywords filter
        keyword_first_name=None,
        keyword_last_name=None,
        keyword_title=None,  # `keyword_title` and `title` are the same. We kept `title` for backward compatibility. Please only use one of them.
        keyword_company=None,
        keyword_school=None,
        network_depth=None,  # DEPRECATED - use network_depths
        title=None,  # DEPRECATED - use keyword_title
        **kwargs,
    ):
        """Perform a LinkedIn search for people.

        :param keywords: Keywords to search on
        :type keywords: str, optional
        :param current_company: A list of company URN IDs (str)
        :type current_company: list, optional
        :param past_companies: A list of company URN IDs (str)
        :type past_companies: list, optional
        :param regions: A list of geo URN IDs (str)
        :type regions: list, optional
        :param industries: A list of industry URN IDs (str)
        :type industries: list, optional
        :param schools: A list of school URN IDs (str)
        :type schools: list, optional
        :param profile_languages: A list of 2-letter language codes (str)
        :type profile_languages: list, optional
        :param contact_interests: A list containing one or both of "proBono" and "boardMember"
        :type contact_interests: list, optional
        :param service_categories: A list of service category URN IDs (str)
        :type service_categories: list, optional
        :param network_depth: Deprecated, use `network_depths`. One of "F", "S" and "O" (first, second and third+ respectively)
        :type network_depth: str, optional
        :param network_depths: A list containing one or many of "F", "S" and "O" (first, second and third+ respectively)
        :type network_depths: list, optional
        :param include_private_profiles: Include private profiles in search results. If False, only public profiles are included. Defaults to False
        :type include_private_profiles: boolean, optional
        :param keyword_first_name: First name
        :type keyword_first_name: str, optional
        :param keyword_last_name: Last name
        :type keyword_last_name: str, optional
        :param keyword_title: Job title
        :type keyword_title: str, optional
        :param keyword_company: Company name
        :type keyword_company: str, optional
        :param keyword_school: School name
        :type keyword_school: str, optional
        :param connection_of: Connection of LinkedIn user, given by profile URN ID
        :type connection_of: str, optional

        :return: List of profiles (minimal data only)
        :rtype: list
        """
        filters = ["resultType->PEOPLE"]
        if connection_of:
            filters.append(f"connectionOf->{connection_of}")
        if network_depths:
            filters.append(f'network->{"|".join(network_depths)}')
        elif network_depth:
            filters.append(f"network->{network_depth}")
        if regions:
            filters.append(f'geoUrn->{"|".join(regions)}')
        if industries:
            filters.append(f'industry->{"|".join(industries)}')
        if current_company:
            filters.append(f'currentCompany->{"|".join(current_company)}')
        if past_companies:
            filters.append(f'pastCompany->{"|".join(past_companies)}')
        if profile_languages:
            filters.append(f'profileLanguage->{"|".join(profile_languages)}')
        if nonprofit_interests:
            filters.append(
                f'nonprofitInterest->{"|".join(nonprofit_interests)}')
        if schools:
            filters.append(f'schools->{"|".join(schools)}')
        if service_categories:
            filters.append(f'serviceCategory->{"|".join(service_categories)}')
        # `Keywords` filter
        keyword_title = keyword_title if keyword_title else title
        if keyword_first_name:
            filters.append(f"firstName->{keyword_first_name}")
        if keyword_last_name:
            filters.append(f"lastName->{keyword_last_name}")
        if keyword_title:
            filters.append(f"title->{keyword_title}")
        if keyword_company:
            filters.append(f"company->{keyword_company}")
        if keyword_school:
            filters.append(f"school->{keyword_school}")

        params = {"filters": "List({})".format(",".join(filters))}

        if keywords:
            params["keywords"] = keywords

        data = self.search(params, **kwargs)

        results = []
        for item in data:
            if not include_private_profiles and "publicIdentifier" not in item:
                continue
            results.append({
                "urn_id":
                get_id_from_urn(item.get("targetUrn")),
                "distance":
                item.get("memberDistance", {}).get("value"),
                "public_id":
                item.get("publicIdentifier"),
                "tracking_id":
                get_id_from_urn(item.get("trackingUrn")),
            })

        return results

    def search_companies(self, keywords=None, **kwargs):
        """Perform a LinkedIn search for companies.

        :param keywords: A list of search keywords (str)
        :type keywords: list, optional

        :return: List of companies
        :rtype: list
        """
        filters = ["resultType->COMPANIES"]

        params = {
            "filters": "List({})".format(",".join(filters)),
            "queryContext": "List(spellCorrectionEnabled->true)",
        }

        if keywords:
            params["keywords"] = keywords

        data = self.search(params, **kwargs)

        results = []
        for item in data:
            if item.get("type") != "COMPANY":
                continue
            results.append({
                "urn": item.get("targetUrn"),
                "urn_id": get_id_from_urn(item.get("targetUrn")),
                "name": item.get("title", {}).get("text"),
                "headline": item.get("headline", {}).get("text"),
                "subline": item.get("subline", {}).get("text"),
            })

        return results

    def search_jobs(
        self,
        keywords=None,
        companies=None,
        experience=None,
        job_type=None,
        job_title=None,
        industries=None,
        location_name=None,
        remote=True,
        listed_at=86400,
        limit=-1,
        offset=0,
        **kwargs,
    ):
        """Perform a LinkedIn search for jobs.

        :param keywords: Search keywords (str)
        :type keywords: str, optional
        :param companies: A list of company URN IDs (str)
        :type companies: list, optional
        :param experience: A list of experience levels, one or many of "1", "2", "3", "4", "5" and "6" (internship, entry level, associate, mid-senior level, director and executive, respectively)
        :type experience: list, optional
        :param job_type:  A list of job types , one or many of "F", "C", "P", "T", "I", "V", "O" (full-time, contract, part-time, temporary, internship, volunteer and "other", respectively)
        :type job_type: list, optional
        :param job_title: A list of title URN IDs (str)
        :type job_title: list, optional
        :param industries: A list of industry URN IDs (str)
        :type industries: list, optional
        :param location_name: Name of the location to search within
        :type location_name: str, optional
        :param remote: Whether to include remote jobs. Defaults to True
        :type remote: boolean, optional

        :return: List of jobs
        :rtype: list
        """
        count = Linkedin._MAX_SEARCH_COUNT
        if limit is None:
            limit = -1

        params = {}
        if keywords:
            params["keywords"] = keywords

        filters = ["resultType->JOBS"]
        if companies:
            filters.append(f'company->{"|".join(companies)}')
        if experience:
            filters.append(f'experience->{"|".join(experience)}')
        if job_type:
            filters.append(f'jobType->{"|".join(job_type)}')
        if job_title:
            filters.append(f'title->{"|".join(job_title)}')
        if industries:
            filters.append(f'industry->{"|".join(industries)}')
        if location_name:
            filters.append(f'locationFallback->{"|".join(location_name)}')
        if remote:
            filters.append(f"commuteFeatures->f_WRA")

        results = []
        while True:
            # when we're close to the limit, only fetch what we need to
            if limit > -1 and limit - len(results) < count:
                count = limit - len(results)
            default_params = {
                "decorationId":
                "com.linkedin.voyager.deco.jserp.WebJobSearchHitWithSalary-14",
                "count":
                count,
                "filters":
                f"List({filters})",
                "origin":
                "JOB_SEARCH_RESULTS_PAGE",
                "q":
                "jserpFilters",
                "start":
                len(results) + offset,
                "queryContext":
                "List(primaryHitType->JOBS,spellCorrectionEnabled->true)",
            }
            default_params.update(params)

            res = self._fetch(
                f"/search/hits?{urlencode(default_params, safe='(),')}",
                headers={
                    "accept": "application/vnd.linkedin.normalized+json+2.1"
                },
            )
            data = res.json()

            elements = data.get("included", [])
            results.extend([
                i for i in elements
                if i["$type"] == "com.linkedin.voyager.jobs.JobPosting"
            ])
            # break the loop if we're done searching
            # NOTE: we could also check for the `total` returned in the response.
            # This is in data["data"]["paging"]["total"]
            if ((limit > -1 and len(results) >= limit
                 )  # if our results exceed set limit
                    or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS
                ) or len(elements) == 0:
                break

            self.logger.debug(f"results grew to {len(results)}")

        return results

    def get_profile_contact_info(self, public_id=None, urn_id=None):
        """Fetch contact information for a given LinkedIn profile. Pass a [public_id] or a [urn_id].

        :param public_id: LinkedIn public ID for a profile
        :type public_id: str, optional
        :param urn_id: LinkedIn URN ID for a profile
        :type urn_id: str, optional

        :return: Contact data
        :rtype: dict
        """
        res = self._fetch(
            f"/identity/profiles/{public_id or urn_id}/profileContactInfo")
        data = res.json()

        contact_info = {
            "email_address": data.get("emailAddress"),
            "websites": [],
            "twitter": data.get("twitterHandles"),
            "birthdate": data.get("birthDateOn"),
            "ims": data.get("ims"),
            "phone_numbers": data.get("phoneNumbers", []),
        }

        websites = data.get("websites", [])
        for item in websites:
            if "com.linkedin.voyager.identity.profile.StandardWebsite" in item[
                    "type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.StandardWebsite"][
                        "category"]
            elif "" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.CustomWebsite"][
                        "label"]

            del item["type"]

        contact_info["websites"] = websites

        return contact_info

    def get_profile_skills(self, public_id=None, urn_id=None):
        """Fetch the skills listed on a given LinkedIn profile.

        :param public_id: LinkedIn public ID for a profile
        :type public_id: str, optional
        :param urn_id: LinkedIn URN ID for a profile
        :type urn_id: str, optional


        :return: List of skill objects
        :rtype: list
        """
        params = {"count": 100, "start": 0}
        res = self._fetch(f"/identity/profiles/{public_id or urn_id}/skills",
                          params=params)
        data = res.json()

        skills = data.get("elements", [])
        for item in skills:
            del item["entityUrn"]

        return skills

    def get_profile(self, public_id=None, urn_id=None):
        """Fetch data for a given LinkedIn profile.

        :param public_id: LinkedIn public ID for a profile
        :type public_id: str, optional
        :param urn_id: LinkedIn URN ID for a profile
        :type urn_id: str, optional

        :return: Profile data
        :rtype: dict
        """
        # NOTE this still works for now, but will probably eventually have to be converted to
        # https://www.linkedin.com/voyager/api/identity/profiles/ACoAAAKT9JQBsH7LwKaE9Myay9WcX8OVGuDq9Uw
        res = self._fetch(
            f"/identity/profiles/{public_id or urn_id}/profileView")

        data = res.json()
        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        # massage [profile] data
        profile = data["profile"]
        if "miniProfile" in profile:
            if "picture" in profile["miniProfile"]:
                profile["displayPictureUrl"] = profile["miniProfile"][
                    "picture"]["com.linkedin.common.VectorImage"]["rootUrl"]

                images_data = profile["miniProfile"]["picture"][
                    "com.linkedin.common.VectorImage"]["artifacts"]
                for img in images_data:
                    w, h, url_segment = itemgetter(
                        "width", "height",
                        "fileIdentifyingUrlPathSegment")(img)
                    profile[f"img_{w}_{h}"] = url_segment

            profile["profile_id"] = get_id_from_urn(
                profile["miniProfile"]["entityUrn"])
            profile["profile_urn"] = profile["miniProfile"]["entityUrn"]
            profile["member_urn"] = profile["miniProfile"]["objectUrn"]

            del profile["miniProfile"]

        del profile["defaultLocale"]
        del profile["supportedLocales"]
        del profile["versionTag"]
        del profile["showEducationOnProfileTopCard"]

        # massage [experience] data
        experience = data["positionView"]["elements"]
        for item in experience:
            if "company" in item and "miniCompany" in item["company"]:
                if "logo" in item["company"]["miniCompany"]:
                    logo = item["company"]["miniCompany"]["logo"].get(
                        "com.linkedin.common.VectorImage")
                    if logo:
                        item["companyLogoUrl"] = logo["rootUrl"]
                del item["company"]["miniCompany"]

        profile["experience"] = experience

        # massage [education] data
        education = data["educationView"]["elements"]
        for item in education:
            if "school" in item:
                if "logo" in item["school"]:
                    item["school"]["logoUrl"] = item["school"]["logo"][
                        "com.linkedin.common.VectorImage"]["rootUrl"]
                    del item["school"]["logo"]

        profile["education"] = education

        # massage [languages] data
        languages = data["languageView"]["elements"]
        for item in languages:
            del item["entityUrn"]
        profile["languages"] = languages

        # massage [publications] data
        publications = data["publicationView"]["elements"]
        for item in publications:
            del item["entityUrn"]
            for author in item.get("authors", []):
                del author["entityUrn"]
        profile["publications"] = publications

        # massage [certifications] data
        certifications = data["certificationView"]["elements"]
        for item in certifications:
            del item["entityUrn"]
        profile["certifications"] = certifications

        # massage [volunteer] data
        volunteer = data["volunteerExperienceView"]["elements"]
        for item in volunteer:
            del item["entityUrn"]
        profile["volunteer"] = volunteer

        # massage [honors] data
        honors = data["honorView"]["elements"]
        for item in honors:
            del item["entityUrn"]
        profile["honors"] = honors

        return profile

    def get_profile_connections(self, urn_id):
        """Fetch first-degree connections for a given LinkedIn profile.

        :param urn_id: LinkedIn URN ID for a profile
        :type urn_id: str

        :return: List of search results
        :rtype: list
        """
        return self.search_people(connection_of=urn_id, network_depth="F")

    def get_company_updates(self,
                            public_id=None,
                            urn_id=None,
                            max_results=None,
                            results=[]):
        """Fetch company updates (news activity) for a given LinkedIn company.

        :param public_id: LinkedIn public ID for a company
        :type public_id: str, optional
        :param urn_id: LinkedIn URN ID for a company
        :type urn_id: str, optional

        :return: List of company update objects
        :rtype: list
        """
        params = {
            "companyUniversalName": {public_id or urn_id},
            "q": "companyFeedByUniversalName",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch(f"/feed/updates", params=params)

        data = res.json()

        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results) or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_company_updates(
            public_id=public_id,
            urn_id=urn_id,
            results=results,
            max_results=max_results,
        )

    def get_profile_updates(self,
                            public_id=None,
                            urn_id=None,
                            max_results=None,
                            results=[]):
        """Fetch profile updates (newsfeed activity) for a given LinkedIn profile.

        :param public_id: LinkedIn public ID for a profile
        :type public_id: str, optional
        :param urn_id: LinkedIn URN ID for a profile
        :type urn_id: str, optional

        :return: List of profile update objects
        :rtype: list
        """
        params = {
            "profileId": {public_id or urn_id},
            "q": "memberShareFeed",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch(f"/feed/updates", params=params)

        data = res.json()

        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results) or
            (max_results is not None and
             len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS)):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_profile_updates(
            public_id=public_id,
            urn_id=urn_id,
            results=results,
            max_results=max_results,
        )

    def get_current_profile_views(self):
        """Get profile view statistics, including chart data.

        :return: Profile view data
        :rtype: dict
        """
        res = self._fetch(f"/identity/wvmpCards")

        data = res.json()

        return data["elements"][0]["value"][
            "com.linkedin.voyager.identity.me.wvmpOverview.WvmpViewersCard"][
                "insightCards"][0]["value"][
                    "com.linkedin.voyager.identity.me.wvmpOverview.WvmpSummaryInsightCard"][
                        "numViews"]

    def get_school(self, public_id):
        """Fetch data about a given LinkedIn school.

        :param public_id: LinkedIn public ID for a school
        :type public_id: str

        :return: School data
        :rtype: dict
        """
        params = {
            "decorationId":
            "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch(f"/organization/companies?{urlencode(params)}")

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data))
            return {}

        school = data["elements"][0]

        return school

    def get_company(self, public_id):
        """Fetch data about a given LinkedIn company.

        :param public_id: LinkedIn public ID for a company
        :type public_id: str

        :return: Company data
        :rtype: dict
        """
        params = {
            "decorationId":
            "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch(f"/organization/companies", params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        company = data["elements"][0]

        return company

    def get_conversation_details(self, profile_urn_id):
        """Fetch conversation (message thread) details for a given LinkedIn profile.

        :param profile_urn_id: LinkedIn URN ID for a profile
        :type profile_urn_id: str

        :return: Conversation data
        :rtype: dict
        """
        # passing `params` doesn't work properly, think it's to do with List().
        # Might be a bug in `requests`?
        res = self._fetch(f"/messaging/conversations?\
            keyVersion=LEGACY_INBOX&q=participants&recipients=List({profile_urn_id})"
                          )

        data = res.json()

        item = data["elements"][0]
        item["id"] = get_id_from_urn(item["entityUrn"])

        return item

    def get_conversations(self):
        """Fetch list of conversations the user is in.

        :return: List of conversations
        :rtype: list
        """
        params = {"keyVersion": "LEGACY_INBOX"}

        res = self._fetch(f"/messaging/conversations", params=params)

        return res.json()

    def get_conversation(self, conversation_urn_id):
        """Fetch data about a given conversation.

        :param conversation_urn_id: LinkedIn URN ID for a conversation
        :type conversation_urn_id: str

        :return: Conversation data
        :rtype: dict
        """
        res = self._fetch(
            f"/messaging/conversations/{conversation_urn_id}/events")

        return res.json()

    def send_message(self,
                     message_body,
                     conversation_urn_id=None,
                     recipients=None):
        """Send a message to a given conversation.

        :param message_body: LinkedIn URN ID for a conversation
        :type message_body: str
        :param conversation_urn_id: LinkedIn URN ID for a conversation
        :type conversation_urn_id: str, optional
        :param recipients: List of profile urn id's
        :type recipients: list, optional

        :return: Error state. If True, an error occured.
        :rtype: boolean
        """
        params = {"action": "create"}

        if not (conversation_urn_id or recipients):
            self.logger.debug(
                "Must provide [conversation_urn_id] or [recipients].")
            return True

        message_event = {
            "eventCreate": {
                "value": {
                    "com.linkedin.voyager.messaging.create.MessageCreate": {
                        "body": message_body,
                        "attachments": [],
                        "attributedBody": {
                            "text": message_body,
                            "attributes": [],
                        },
                        "mediaAttachments": [],
                    }
                }
            }
        }

        if conversation_urn_id and not recipients:
            res = self._post(
                f"/messaging/conversations/{conversation_urn_id}/events",
                params=params,
                data=json.dumps(message_event),
            )
        elif recipients and not conversation_urn_id:
            message_event["recipients"] = recipients
            message_event["subtype"] = "MEMBER_TO_MEMBER"
            payload = {
                "keyVersion": "LEGACY_INBOX",
                "conversationCreate": message_event,
            }
            res = self._post(
                f"/messaging/conversations",
                params=params,
                data=json.dumps(payload),
            )

        return res.status_code != 201

    def mark_conversation_as_seen(self, conversation_urn_id):
        """Send 'seen' to a given conversation.

        :param conversation_urn_id: LinkedIn URN ID for a conversation
        :type conversation_urn_id: str

        :return: Error state. If True, an error occured.
        :rtype: boolean
        """
        payload = json.dumps({"patch": {"$set": {"read": True}}})

        res = self._post(f"/messaging/conversations/{conversation_urn_id}",
                         data=payload)

        return res.status_code != 200

    def get_user_profile(self, use_cache=True):
        """Get the current user profile. If not cached, a network request will be fired.

        :return: Profile data for currently logged in user
        :rtype: dict
        """
        me_profile = self.client.metadata.get("me")
        if not self.client.metadata.get("me") or not use_cache:
            res = self._fetch(f"/me")
            me_profile = res.json()
            # cache profile
            self.client.metadata["me"] = me_profile

        return me_profile

    def get_invitations(self, start=0, limit=3):
        """Fetch connection invitations for the currently logged in user.

        :param start: How much to offset results by
        :type start: int
        :param limit: Maximum amount of invitations to return
        :type limit: int

        :return: List of invitation objects
        :rtype: list
        """
        params = {
            "start": start,
            "count": limit,
            "includeInsights": True,
            "q": "receivedInvitation",
        }

        res = self._fetch(
            f"{self.client.API_BASE_URL}/relationships/invitationViews",
            params=params,
        )

        if res.status_code != 200:
            return []

        response_payload = res.json()
        return [
            element["invitation"] for element in response_payload["elements"]
        ]

    def reply_invitation(self,
                         invitation_entity_urn,
                         invitation_shared_secret,
                         action="accept"):
        """Respond to a connection invitation. By default, accept the invitation.

        :param invitation_entity_urn: URN ID of the invitation
        :type invitation_entity_urn: int
        :param invitation_shared_secret: Shared secret of invitation
        :type invitation_shared_secret: str
        :param action: "accept" or "reject". Defaults to "accept"
        :type action: str, optional

        :return: Success state. True if successful
        :rtype: boolean
        """
        invitation_id = get_id_from_urn(invitation_entity_urn)
        params = {"action": action}
        payload = json.dumps({
            "invitationId": invitation_id,
            "invitationSharedSecret": invitation_shared_secret,
            "isGenericInvitation": False,
        })

        res = self._post(
            f"{self.client.API_BASE_URL}/relationships/invitations/{invitation_id}",
            params=params,
            data=payload,
        )

        return res.status_code == 200

    def generateTrackingId(self):
        """Generates and returns a random trackingId

        :return: Random trackingId string
        :rtype: str
        """
        random_int_array = [random.randrange(256) for _ in range(16)]
        rand_byte_array = bytearray(random_int_array)
        return str(base64.b64encode(rand_byte_array))[2:-1]

    def add_connection(self, profile_public_id, message="", profile_urn=None):
        """Add a given profile id as a connection.

        :param profile_public_id: public ID of a LinkedIn profile
        :type profile_public_id: str
        :param message: message to send along with connection request
        :type profile_urn: str, optional
        :param profile_urn: member URN for the given LinkedIn profile
        :type profile_urn: str, optional

        :return: Error state. True if error occurred
        :rtype: boolean
        """

        ## Validating message length (max size is 300 characters)
        if len(message) > 300:
            self.logger.info("Message too long. Max size is 300 characters")
            return False

        if not profile_urn:
            profile_urn_string = self.get_profile(
                public_id=profile_public_id)["profile_urn"]
            # Returns string of the form 'urn:li:fs_miniProfile:ACoAACX1hoMBvWqTY21JGe0z91mnmjmLy9Wen4w'
            # We extract the last part of the string
            profile_urn = profile_urn_string.split(":")[-1]

        trackingId = self.generateTrackingId()
        payload = (
            '{"trackingId":"' + trackingId + '", "message":"' + message +
            '", "invitations":[], "excludeInvitations":[],"invitee":{"com.linkedin.voyager.growth.invitation.InviteeProfile":\
            {"profileId":"' + profile_urn + '"' + "}}}")
        res = self._post(
            "/growth/normInvitations",
            data=payload,
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        return res.status_code != 201

    def remove_connection(self, public_profile_id):
        """Remove a given profile as a connection.

        :param public_profile_id: public ID of a LinkedIn profile
        :type public_profile_id: str

        :return: Error state. True if error occurred
        :rtype: boolean
        """
        res = self._post(
            f"/identity/profiles/{public_profile_id}/profileActions?action=disconnect",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        return res.status_code != 200

    def track(self, eventBody, eventInfo):
        payload = {"eventBody": eventBody, "eventInfo": eventInfo}
        res = self._post(
            "/li/track",
            base_request=True,
            headers={
                "accept": "*/*",
                "content-type": "text/plain;charset=UTF-8",
            },
            data=json.dumps(payload),
        )

        return res.status_code != 200

    def view_profile(
        self,
        target_profile_public_id,
        target_profile_member_urn_id=None,
        network_distance=None,
    ):
        """View a profile, notifying the user that you "viewed" their profile.

        Provide [target_profile_member_urn_id] and [network_distance] to save 2 network requests and
        speed up the execution of this function.

        :param target_profile_public_id: public ID of a LinkedIn profile
        :type target_profile_public_id: str
        :param network_distance: How many degrees of separation exist e.g. 2
        :type network_distance: int, optional
        :param target_profile_member_urn_id: member URN id for target profile
        :type target_profile_member_urn_id: str, optional

        :return: Error state. True if error occurred
        :rtype: boolean
        """
        me_profile = self.get_user_profile()

        if not target_profile_member_urn_id:
            profile = self.get_profile(public_id=target_profile_public_id)
            target_profile_member_urn_id = int(
                get_id_from_urn(profile["member_urn"]))

        if not network_distance:
            profile_network_info = self.get_profile_network_info(
                public_profile_id=target_profile_public_id)
            network_distance = int(profile_network_info["distance"].get(
                "value", "DISTANCE_2").split("_")[1])

        viewer_privacy_setting = "F"
        me_member_id = me_profile["plainId"]

        client_application_instance = self.client.metadata[
            "clientApplicationInstance"]

        eventBody = {
            "viewerPrivacySetting": viewer_privacy_setting,
            "networkDistance": network_distance,
            "vieweeMemberUrn": f"urn:li:member:{target_profile_member_urn_id}",
            "profileTrackingId": self.client.metadata["clientPageInstanceId"],
            "entityView": {
                "viewType": "profile-view",
                "viewerId": me_member_id,
                "targetId": target_profile_member_urn_id,
            },
            "header": {
                "pageInstance": {
                    "pageUrn": "urn:li:page:d_flagship3_profile_view_base",
                    "trackingId": self.client.metadata["clientPageInstanceId"],
                },
                "time": int(time()),
                "version": client_application_instance["version"],
                "clientApplicationInstance": client_application_instance,
            },
            "requestHeader": {
                "interfaceLocale": "en_US",
                "pageKey": "d_flagship3_profile_view_base",
                "path": f"/in/{target_profile_member_urn_id}/",
                "referer": "https://www.linkedin.com/feed/",
            },
        }

        return self.track(
            eventBody,
            {
                "appId": "com.linkedin.flagship3.d_web",
                "eventName": "ProfileViewEvent",
                "topicName": "ProfileViewEvent",
            },
        )

    def get_profile_privacy_settings(self, public_profile_id):
        """Fetch privacy settings for a given LinkedIn profile.

        :param public_profile_id: public ID of a LinkedIn profile
        :type public_profile_id: str

        :return: Privacy settings data
        :rtype: dict
        """
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/privacySettings",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})

    def get_profile_member_badges(self, public_profile_id):
        """Fetch badges for a given LinkedIn profile.

        :param public_profile_id: public ID of a LinkedIn profile
        :type public_profile_id: str

        :return: Badges data
        :rtype: dict
        """
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/memberBadges",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})

    def get_profile_network_info(self, public_profile_id):
        """Fetch network information for a given LinkedIn profile.

        :param public_profile_id: public ID of a LinkedIn profile
        :type public_profile_id: str

        :return: Network data
        :rtype: dict
        """
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/networkinfo",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})

    def unfollow_entity(self, urn_id):
        """Unfollow a given entity.

        :param urn_id: URN ID of entity to unfollow
        :type urn_id: str

        :return: Error state. Returns True if error occurred
        :rtype: boolean
        """
        payload = {"urn": f"urn:li:fs_followingInfo:{urn_id}"}
        res = self._post(
            "/feed/follows?action=unfollowByEntityUrn",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
            data=json.dumps(payload),
        )

        err = False
        if res.status_code != 200:
            err = True

        return err

    def _get_list_feed_posts_and_list_feed_urns(self,
                                                limit=-1,
                                                offset=0,
                                                exclude_promoted_posts=True):
        """Get a list of URNs from feed sorted by 'Recent' and a list of yet
        unsorted posts, each one of them containing a dict per post.

        :param limit: Maximum length of the returned list, defaults to -1 (no limit)
        :type limit: int, optional
        :param offset: Index to start searching from
        :type offset: int, optional
        :param exclude_promoted_posts: Exclude from the output promoted posts
        :type exclude_promoted_posts: bool, optional

        :return: List of posts and list of URNs
        :rtype: (list, list)
        """
        _PROMOTED_STRING = "Promoted"
        _PROFILE_URL = f"{self.client.LINKEDIN_BASE_URL}/in/"

        l_posts = []
        l_urns = []

        # If count>100 API will return HTTP 400
        count = Linkedin._MAX_UPDATE_COUNT
        if limit == -1:
            limit = Linkedin._MAX_UPDATE_COUNT

        # 'l_urns' equivalent to other functions 'results' variable
        l_urns = []

        while True:

            # when we're close to the limit, only fetch what we need to
            if limit > -1 and limit - len(l_urns) < count:
                count = limit - len(l_urns)
            params = {
                "count": str(count),
                "q": "chronFeed",
                "start": len(l_urns) + offset,
            }
            res = self._fetch(
                f"/feed/updatesV2",
                params=params,
                headers={
                    "accept": "application/vnd.linkedin.normalized+json+2.1"
                },
            )
            """
            Response includes two keya:
            - ['Data']['*elements']. It includes the posts URNs always
            properly sorted as 'Recent', including yet sponsored posts. The
            downside is that fetching one by one the posts is slower. We will
            save the URNs to later on build a sorted list of posts purging
            promotions
            - ['included']. List with all the posts attributes, but not sorted as
            'Recent' and including promoted posts
            """
            l_raw_posts = res.json().get("included", {})
            l_raw_urns = res.json().get("data", {}).get("*elements", [])

            l_new_posts = parse_list_raw_posts(l_raw_posts,
                                               self.client.LINKEDIN_BASE_URL)
            l_posts.extend(l_new_posts)

            l_urns.extend(parse_list_raw_urns(l_raw_urns))

            # break the loop if we're done searching
            # NOTE: we could also check for the `total` returned in the response.
            # This is in data["data"]["paging"]["total"]
            if ((limit > -1 and len(l_urns) >= limit
                 )  # if our results exceed set limit
                    or len(l_urns) / count >= Linkedin._MAX_REPEATED_REQUESTS
                ) or len(l_raw_urns) == 0:
                break

            self.logger.debug(f"results grew to {len(l_urns)}")

        return l_posts, l_urns

    def get_feed_posts(self, limit=-1, offset=0, exclude_promoted_posts=True):
        """Get a list of URNs from feed sorted by 'Recent'

        :param limit: Maximum length of the returned list, defaults to -1 (no limit)
        :type limit: int, optional
        :param offset: Index to start searching from
        :type offset: int, optional
        :param exclude_promoted_posts: Exclude from the output promoted posts
        :type exclude_promoted_posts: bool, optional

        :return: List of URNs
        :rtype: list
        """
        l_posts, l_urns = self._get_list_feed_posts_and_list_feed_urns(
            limit, offset, exclude_promoted_posts)
        return get_list_posts_sorted_without_promoted(l_urns, l_posts)
Beispiel #6
0
class Linkedin(object):
    """
    Class for accessing Linkedin API.
    """

    _MAX_SEARCH_COUNT = 49  # max seems to be 49
    _MAX_REPEATED_REQUESTS = 200  # VERY conservative max requests count to avoid rate-limit

    def __init__(self, username, password):
        self.client = Client()
        self.client.authenticate(username, password)

        self.logger = logger

    def search(self, params, max_results=None, results=[]):
        """
        Do a search.
        """
        sleep(random.randint(
            0, 1))  # sleep a random duration to try and evade suspention

        count = max_results if max_results and max_results <= Linkedin._MAX_SEARCH_COUNT else Linkedin._MAX_SEARCH_COUNT
        default_params = {
            "count": count,
            "guides": "List()",
            "origin": "GLOBAL_SEARCH_HEADER",
            "q": "guided",
            "start": len(results),
        }

        default_params.update(params)

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/search/cluster",
            params=default_params)
        data = res.json()

        total_found = data.get("paging", {}).get("total")
        if total_found == 0 or total_found is None:
            self.logger.debug("found none...")
            return []

        # recursive base case
        if (len(data["elements"]) == 0
                or (max_results is not None and len(results) >= max_results)
                or len(results) >= total_found or
                len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS):
            return results

        results.extend(data["elements"][0]["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.search(params, results=results, max_results=max_results)

    def search_people(self,
                      keywords=None,
                      connection_of=None,
                      network_depth=None,
                      regions=None,
                      industries=None):
        """
        Do a people search.
        """
        guides = ["v->PEOPLE"]
        if connection_of:
            guides.append(f"facetConnectionOf->{connection_of}")
        if network_depth:
            guides.append(f"facetNetwork->{network_depth}")
        if regions:
            guides.append(f'facetGeoRegion->{"|".join(regions)}')
        if industries:
            guides.append(f'facetIndustry->{"|".join(industries)}')

        params = {"guides": "List({})".format(",".join(guides))}

        if keywords:
            params["keywords"] = keywords

        data = self.search(params)

        results = []
        for item in data:
            search_profile = item["hitInfo"][
                "com.linkedin.voyager.search.SearchProfile"]
            profile_id = search_profile["id"]
            distance = search_profile["distance"]["value"]

            results.append({
                "urn_id":
                profile_id,
                "distance":
                distance,
                "public_id":
                search_profile["miniProfile"]["publicIdentifier"],
            })

        return results

    def get_profile_contact_info(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/identity/profiles/{public_id or urn_id}/profileContactInfo"
        )
        data = res.json()

        contact_info = {
            "email_address": data.get("emailAddress"),
            "websites": [],
            "phone_numbers": data.get("phoneNumbers", []),
        }

        websites = data.get("websites", [])
        for item in websites:
            if "com.linkedin.voyager.identity.profile.StandardWebsite" in item[
                    "type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.StandardWebsite"][
                        "category"]
            elif "" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.CustomWebsite"][
                        "label"]

            del item["type"]

        contact_info["websites"] = websites

        return contact_info

    def get_profile(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        sleep(random.randint(
            2, 5))  # sleep a random duration to try and evade suspention
        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/identity/profiles/{public_id or urn_id}/profileView"
        )

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        # massage [profile] data
        profile = data["profile"]
        if "miniProfile" in profile:
            if "picture" in profile["miniProfile"]:
                profile["displayPictureUrl"] = profile["miniProfile"][
                    "picture"]["com.linkedin.common.VectorImage"]["rootUrl"]
            profile["profile_id"] = profile["miniProfile"]["entityUrn"].split(
                ":")[3]

            del profile["miniProfile"]

        del profile["defaultLocale"]
        del profile["supportedLocales"]
        del profile["versionTag"]
        del profile["showEducationOnProfileTopCard"]

        # massage [experience] data
        experience = data["positionView"]["elements"]
        for item in experience:
            if "company" in item and "miniCompany" in item["company"]:
                if "logo" in item["company"]["miniCompany"]:
                    logo = item["company"]["miniCompany"]["logo"].get(
                        "com.linkedin.common.VectorImage")
                    if logo:
                        item["companyLogoUrl"] = logo["rootUrl"]
                del item["company"]["miniCompany"]

        profile["experience"] = experience

        # massage [skills] data
        skills = [item["name"] for item in data["skillView"]["elements"]]

        profile["skills"] = skills

        # massage [education] data
        education = data["educationView"]["elements"]
        for item in education:
            if "school" in item:
                if "logo" in item["school"]:
                    item["school"]["logoUrl"] = item["school"]["logo"][
                        "com.linkedin.common.VectorImage"]["rootUrl"]
                    del item["school"]["logo"]

        profile["education"] = education

        return profile

    def get_profile_connections(self, urn_id):
        """
        Return a list of profile ids connected to profile of given [urn_id]
        """
        return self.search_people(connection_of=urn_id, network_depth="F")

    def get_school(self, public_id):
        """
        Return data for a single school.

        [public_id] - public identifier i.e. uq
        """
        sleep(random.randint(
            2, 5))  # sleep a random duration to try and evade suspention
        params = {
            "decoration": ("""
                (
                autoGenerated,backgroundCoverImage,
                companyEmployeesSearchPageUrl,companyPageUrl,confirmedLocations*,coverPhoto,dataVersion,description,
                entityUrn,followingInfo,foundedOn,headquarter,jobSearchPageUrl,lcpTreatment,logo,name,type,overviewPhoto,
                paidCompany,partnerCompanyUrl,partnerLogo,partnerLogoImage,rankForTopCompanies,salesNavigatorCompanyUrl,
                school,showcase,staffCount,staffCountRange,staffingCompany,topCompaniesListName,universalName,url,
                companyIndustries*,industries,specialities,
                acquirerCompany~(entityUrn,logo,name,industries,followingInfo,url,paidCompany,universalName),
                affiliatedCompanies*~(entityUrn,logo,name,industries,followingInfo,url,paidCompany,universalName),
                groups*~(entityUrn,largeLogo,groupName,memberCount,websiteUrl,url),
                showcasePages*~(entityUrn,logo,name,industries,followingInfo,url,description,universalName)
                )
                """),
            "q":
            "universalName",
            "universalName":
            public_id
        }

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/organization/companies",
            params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        school = data["elements"][0]

        return school

    def get_company(self, public_id):
        """
        Return data for a single company.

        [public_id] - public identifier i.e. univeristy-of-queensland
        """
        sleep(random.randint(
            2, 5))  # sleep a random duration to try and evade suspention
        params = {
            "decoration": ("""
                (
                affiliatedCompaniesWithEmployeesRollup,affiliatedCompaniesWithJobsRollup,articlePermalinkForTopCompanies,
                autoGenerated,backgroundCoverImage,companyEmployeesSearchPageUrl,
                companyPageUrl,confirmedLocations*,coverPhoto,dataVersion,description,entityUrn,followingInfo,
                foundedOn,headquarter,jobSearchPageUrl,lcpTreatment,logo,name,type,overviewPhoto,paidCompany,
                partnerCompanyUrl,partnerLogo,partnerLogoImage,permissions,rankForTopCompanies,
                salesNavigatorCompanyUrl,school,showcase,staffCount,staffCountRange,staffingCompany,
                topCompaniesListName,universalName,url,companyIndustries*,industries,specialities,
                acquirerCompany~(entityUrn,logo,name,industries,followingInfo,url,paidCompany,universalName),
                affiliatedCompanies*~(entityUrn,logo,name,industries,followingInfo,url,paidCompany,universalName),
                groups*~(entityUrn,largeLogo,groupName,memberCount,websiteUrl,url),
                showcasePages*~(entityUrn,logo,name,industries,followingInfo,url,description,universalName)
                )
                """),
            "q":
            "universalName",
            "universalName":
            public_id
        }

        res = self.client.session.get(
            f"{self.client.API_BASE_URL}/organization/companies",
            params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        company = data["elements"][0]

        return company
Beispiel #7
0
class Linkedin(object):
    """
    Class for accessing Linkedin API.
    """

    _MAX_UPDATE_COUNT = 100  # max seems to be 100
    _MAX_SEARCH_COUNT = 49  # max seems to be 49
    _MAX_REPEATED_REQUESTS = (
        200  # VERY conservative max requests count to avoid rate-limit
    )

    def __init__(
        self,
        username,
        password,
        *,
        authenticate=True,
        refresh_cookies=False,
        debug=False,
        proxies={},
        preloaded_cookies=None
    ):
        self.client = Client(
            refresh_cookies=refresh_cookies, debug=debug, proxies=proxies
        )
        logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
        self.logger = logger

        if authenticate:
            self.client.authenticate(username, password, preloaded_cookies)

    def _fetch(self, uri, evade=default_evade, **kwargs):
        """
        GET request to Linkedin API
        """
        evade()

        url = f"{self.client.API_BASE_URL}{uri}"
        return self.client.session.get(url, **kwargs)

    def _post(self, uri, evade=default_evade, **kwargs):
        """
        POST request to Linkedin API
        """
        evade()

        url = f"{self.client.API_BASE_URL}{uri}"
        return self.client.session.post(url, **kwargs)

    def search(self, params, limit=None, results=[]):
        """
        Do a search.
        """
        count = (
            limit
            if limit and limit <= Linkedin._MAX_SEARCH_COUNT
            else Linkedin._MAX_SEARCH_COUNT
        )
        default_params = {
            "count": str(count),
            "filters": "List()",
            "origin": "GLOBAL_SEARCH_HEADER",
            "q": "all",
            "start": len(results),
            "queryContext": "List(spellCorrectionEnabled->true,relatedSearchesEnabled->true,kcardTypes->PROFILE|COMPANY)",
        }

        default_params.update(params)

        res = self._fetch(
            f"/search/blended?{urlencode(default_params, safe='(),')}",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        data = res.json()

        new_elements = []
        for i in range(len(data["data"]["elements"])):
            new_elements.extend(data["data"]["elements"][i]["elements"])
            # not entirely sure what extendedElements generally refers to - keyword search gives back a single job?
            # new_elements.extend(data["data"]["elements"][i]["extendedElements"])

        results.extend(new_elements)
        results = results[
            :limit
        ]  # always trim results, no matter what the request returns

        # recursive base case
        if (
            limit is not None
            and (
                len(results) >= limit  # if our results exceed set limit
                or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS
            )
        ) or len(new_elements) == 0:
            return results

        self.logger.debug(f"results grew to {len(results)}")

        return self.search(params, results=results, limit=limit)

    def search_jobs(self, params, max=25, start=0):
        """
        Do a search.
        """

        jobs = {}
        companies = {}

        default_params = {
            "count": "25",
            "start": str(start),
            "decorationId": "com.linkedin.voyager.deco.jserp.WebJobSearchHit-22",
            "facetEnabled": "false",
            "isRequestPrefetch": "true",
            "keywords": "",
            "origin": "SEARCH_ON_JOBS_HOME_PREFETCH",
            "q": "jserpAll",
            "query": "search",
            "topNRequestedFlavors": "List(HIDDEN_GEM,IN_NETWORK,SCHOOL_RECRUIT,COMPANY_RECRUIT,SALARY,"
            "JOB_SEEKER_QUALIFIED,PREFERRED_COMMUTE)",
        }

        default_params.update(params)
        n_attempts = 0
        while len(jobs) < max:
            length_before = len(jobs)
            res = self._fetch(
                f"/search/hits?{urlencode(default_params, safe='(),')}",
                headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
            )

            data = res.json()
            if not data or not data["included"]:
                break
            for job in data["included"]:
                company_details = job.get("companyDetails")
                if company_details:
                    company_urn = company_details.get("company")
                    company_urn_id = get_id_from_urn(company_urn) if company_urn else ""
                    job_urn_id = get_id_from_urn(job["entityUrn"])
                    if not jobs.get(job_urn_id):
                        jobs[job_urn_id] = {
                            "title": job.get("title"),
                            "companyId": company_urn_id,
                            "location": job.get("formattedLocation"),
                            "listedAt": job.get("listedAt"),
                            "expireAt": job.get("expireAt"),
                        }
                        apply_method = job.get("applyMethod")
                        if apply_method:
                            company_apply_url = apply_method.get("companyApplyUrl")
                            easy_apply_url = apply_method.get("easyApplyUrl")
                            jobs[job_urn_id]["url"] = (
                                company_apply_url
                                if company_apply_url
                                else easy_apply_url
                            )

                elif "fs_normalized_company" in job.get("entityUrn"):
                    company_urn_id = get_id_from_urn(job.get("entityUrn"))
                    if not companies.get(company_urn_id):
                        companies[company_urn_id] = {"name": job.get("name")}

            if length_before == len(jobs):
                n_attempts += 1
            if n_attempts > 3:
                break
            default_params["start"] = str(int(default_params["start"]) + 25)

        return jobs, companies

    def search_employees(
        self, params={}, current_company_urn_id="", title="", max=12, start=0
    ):
        """
        Do a search.
        """

        people = []

        default_params = {
            "count": "12",
            "educationEndYear": "List()",
            "educationStartYear": "List()",
            "facetCurrentCompany": f"List({current_company_urn_id})",
            "facetCurrentFunction": "List()",
            "facetFieldOfStudy": "List()",
            "facetGeoRegion": "List()",
            "facetNetwork": "List()",
            "facetSchool": "List()",
            "facetSkillExplicit": "List()",
            "keywords": f"List({title})",
            "maxFacetValues": "15",
            "origin": "organization",
            "q": "people",
            "start": f"{start}",
            "supportedFacets": "List(GEO_REGION,SCHOOL,CURRENT_COMPANY,CURRENT_FUNCTION,FIELD_OF_STUDY,SKILL_EXPLICIT,NETWORK)",
        }

        default_params.update(params)
        n_attempts = 0
        while len(people) < max:
            length_before = len(people)
            res = self._fetch(
                f"/search/hits?{urlencode(default_params, safe='(),')}",
                headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
            )

            data = res.json()
            if not data or not data.get("included"):
                if data.get("status") == "429":
                    print("Reached limit")
                    return -1
                break
            for person in data["included"]:
                public_id = person.get("publicIdentifier")
                if public_id:
                    people.append(
                        {
                            "public_id": public_id,
                            "profile_urn_id": get_id_from_urn(person.get("objectUrn")),
                            "first_name": person.get("firstName"),
                            "last_name": person.get("lastName"),
                            "title": person.get("occupation"),
                        }
                    )

            if length_before == len(people):
                n_attempts += 1
            if n_attempts > 3:
                break
            default_params["start"] = str(int(default_params["start"]) + 12)

        return people

    def search_people(
        self,
        keywords=None,
        connection_of=None,
        network_depth=None,
        current_company=None,
        past_companies=None,
        nonprofit_interests=None,
        profile_languages=None,
        regions=None,
        industries=None,
        schools=None,
        title=None,
        include_private_profiles=False,  # profiles without a public id, "Linkedin Member"
        limit=None,
    ):
        """
        Do a people search.
        """
        filters = ["resultType->PEOPLE"]
        if connection_of:
            filters.append(f"connectionOf->{connection_of}")
        if network_depth:
            filters.append(f"network->{network_depth}")
        if regions:
            filters.append(f'geoRegion->{"|".join(regions)}')
        if industries:
            filters.append(f'industry->{"|".join(industries)}')
        if current_company:
            filters.append(f'currentCompany->{"|".join(current_company)}')
        if past_companies:
            filters.append(f'pastCompany->{"|".join(past_companies)}')
        if profile_languages:
            filters.append(f'profileLanguage->{"|".join(profile_languages)}')
        if nonprofit_interests:
            filters.append(f'nonprofitInterest->{"|".join(nonprofit_interests)}')
        if schools:
            filters.append(f'schools->{"|".join(schools)}')
        if title:
            filters.append(f"title->{title}")

        params = {"filters": "List({})".format(",".join(filters))}

        if keywords:
            params["keywords"] = keywords

        data = self.search(params, limit=limit)

        results = []
        for item in data:
            if "publicIdentifier" not in item:
                continue
            results.append(
                {
                    "urn_id": get_id_from_urn(item.get("targetUrn")),
                    "distance": item.get("memberDistance", {}).get("value"),
                    "public_id": item.get("publicIdentifier"),
                }
            )

        return results

    def search_companies(self, keywords=None, limit=None):
        """
        Do a company search.
        """
        filters = ["resultType->COMPANIES"]

        params = {
            "filters": "List({})".format(",".join(filters)),
            "queryContext": "List(spellCorrectionEnabled->true)",
        }

        if keywords:
            params["keywords"] = keywords

        data = self.search(params, limit=limit)

        results = []
        for item in data:
            if item.get("type") != "COMPANY":
                continue
            results.append(
                {
                    "urn": item.get("targetUrn"),
                    "urn_id": get_id_from_urn(item.get("targetUrn")),
                    "name": item.get("title", {}).get("text"),
                    "headline": item.get("headline", {}).get("text"),
                    "subline": item.get("subline", {}).get("text"),
                }
            )

        return results

    def get_profile_contact_info(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        res = self._fetch(
            f"/identity/profiles/{public_id or urn_id}/profileContactInfo"
        )
        data = res.json()

        contact_info = {
            "email_address": data.get("emailAddress"),
            "websites": [],
            "twitter": data.get("twitterHandles"),
            "birthdate": data.get("birthDateOn"),
            "ims": data.get("ims"),
            "phone_numbers": data.get("phoneNumbers", []),
        }

        websites = data.get("websites", [])
        for item in websites:
            if "com.linkedin.voyager.identity.profile.StandardWebsite" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.StandardWebsite"
                ]["category"]
            elif "" in item["type"]:
                item["label"] = item["type"][
                    "com.linkedin.voyager.identity.profile.CustomWebsite"
                ]["label"]

            del item["type"]

        contact_info["websites"] = websites

        return contact_info

    def get_profile_skills(self, public_id=None, urn_id=None):
        """
        Return the skills of a profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        params = {"count": 100, "start": 0}
        res = self._fetch(
            f"/identity/profiles/{public_id or urn_id}/skills", params=params
        )
        data = res.json()

        skills = data.get("elements", [])
        for item in skills:
            del item["entityUrn"]

        return skills

    def get_profile(self, public_id=None, urn_id=None):
        """
        Return data for a single profile.

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        # NOTE this still works for now, but will probably eventually have to be converted to
        # https://www.linkedin.com/voyager/api/identity/profiles/ACoAAAKT9JQBsH7LwKaE9Myay9WcX8OVGuDq9Uw
        res = self._fetch(f"/identity/profiles/{public_id or urn_id}/profileView")

        data = res.json()
        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        # massage [profile] data
        profile = data["profile"]
        if "miniProfile" in profile:
            if "picture" in profile["miniProfile"]:
                profile["displayPictureUrl"] = profile["miniProfile"]["picture"][
                    "com.linkedin.common.VectorImage"
                ]["rootUrl"]
            profile["profile_id"] = get_id_from_urn(profile["miniProfile"]["entityUrn"])

            del profile["miniProfile"]

        del profile["defaultLocale"]
        del profile["supportedLocales"]
        del profile["versionTag"]
        del profile["showEducationOnProfileTopCard"]

        # massage [experience] data
        experience = data["positionView"]["elements"]
        for item in experience:
            if "company" in item and "miniCompany" in item["company"]:
                if "logo" in item["company"]["miniCompany"]:
                    logo = item["company"]["miniCompany"]["logo"].get(
                        "com.linkedin.common.VectorImage"
                    )
                    if logo:
                        item["companyLogoUrl"] = logo["rootUrl"]
                del item["company"]["miniCompany"]

        profile["experience"] = experience

        # massage [skills] data
        # skills = [item["name"] for item in data["skillView"]["elements"]]
        # profile["skills"] = skills

        profile["skills"] = self.get_profile_skills(public_id=public_id, urn_id=urn_id)

        # massage [education] data
        education = data["educationView"]["elements"]
        for item in education:
            if "school" in item:
                if "logo" in item["school"]:
                    item["school"]["logoUrl"] = item["school"]["logo"][
                        "com.linkedin.common.VectorImage"
                    ]["rootUrl"]
                    del item["school"]["logo"]

        profile["education"] = education

        # massage [languages] data
        languages = data["languageView"]["elements"]
        for item in languages:
            del item["entityUrn"]
        profile["languages"] = languages

        # massage [publications] data
        publications = data["publicationView"]["elements"]
        for item in publications:
            del item["entityUrn"]
            for author in item.get("authors", []):
                del author["entityUrn"]
        profile["publications"] = publications

        # massage [certifications] data
        certifications = data["certificationView"]["elements"]
        for item in certifications:
            del item["entityUrn"]
        profile["certifications"] = certifications

        # massage [volunteer] data
        volunteer = data["volunteerExperienceView"]["elements"]
        for item in volunteer:
            del item["entityUrn"]
        profile["volunteer"] = volunteer

        # massage [honors] data
        honors = data["honorView"]["elements"]
        for item in honors:
            del item["entityUrn"]
        profile["honors"] = honors

        return profile

    def get_profile_connections(self, urn_id):
        """
        Return a list of profile ids connected to profile of given [urn_id]
        """
        return self.search_people(connection_of=urn_id, network_depth="F")

    def get_company_updates(
        self, public_id=None, urn_id=None, max_results=None, results=[]
    ):
        """"
        Return a list of company posts

        [public_id] - public identifier ie - microsoft
        [urn_id] - id provided by the related URN
        """
        params = {
            "companyUniversalName": {public_id or urn_id},
            "q": "companyFeedByUniversalName",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch(f"/feed/updates", params=params)

        data = res.json()

        if (
            len(data["elements"]) == 0
            or (max_results is not None and len(results) >= max_results)
            or (
                max_results is not None
                and len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS
            )
        ):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_company_updates(
            public_id=public_id, urn_id=urn_id, results=results, max_results=max_results
        )

    def get_profile_updates(
        self, public_id=None, urn_id=None, max_results=None, results=[]
    ):
        """"
        Return a list of profile posts

        [public_id] - public identifier i.e. tom-quirk-1928345
        [urn_id] - id provided by the related URN
        """
        params = {
            "profileId": {public_id or urn_id},
            "q": "memberShareFeed",
            "moduleKey": "member-share",
            "count": Linkedin._MAX_UPDATE_COUNT,
            "start": len(results),
        }

        res = self._fetch(f"/feed/updates", params=params)

        data = res.json()

        if (
            len(data["elements"]) == 0
            or (max_results is not None and len(results) >= max_results)
            or (
                max_results is not None
                and len(results) / max_results >= Linkedin._MAX_REPEATED_REQUESTS
            )
        ):
            return results

        results.extend(data["elements"])
        self.logger.debug(f"results grew: {len(results)}")

        return self.get_profile_updates(
            public_id=public_id, urn_id=urn_id, results=results, max_results=max_results
        )

    def get_current_profile_views(self):
        """
        Get profile view statistics, including chart data.
        """
        res = self._fetch(f"/identity/wvmpCards")

        data = res.json()

        return data["elements"][0]["value"][
            "com.linkedin.voyager.identity.me.wvmpOverview.WvmpViewersCard"
        ]["insightCards"][0]["value"][
            "com.linkedin.voyager.identity.me.wvmpOverview.WvmpSummaryInsightCard"
        ][
            "numViews"
        ]

    def get_school(self, public_id):
        """
        Return data for a single school.

        [public_id] - public identifier i.e. uq
        """
        params = {
            "decorationId": "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch(f"/organization/companies?{urlencode(params)}")

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data))
            return {}

        school = data["elements"][0]

        return school

    def get_company(self, public_id):
        """
        Return data for a single company.

        [public_id] - public identifier i.e. univeristy-of-queensland
        """
        params = {
            "decorationId": "com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12",
            "q": "universalName",
            "universalName": public_id,
        }

        res = self._fetch(f"/organization/companies", params=params)

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed: {}".format(data["message"]))
            return {}

        company = data["elements"][0]

        return company

    def get_job_posting(self, job_urn_id):
        """
        Return job posting details for a given [job_urn_id], e.g: 1677638156
        """

        params = {
            "decorationId": "com.linkedin.voyager.deco.jobs.web.shared.WebFullJobPosting-39",
            "topN": "1",
            "topNRequestedFlavors": "List(IN_NETWORK,COMPANY_RECRUIT,SCHOOL_RECRUIT,HIDDEN_GEM)",
        }

        res = self._fetch(
            f"/jobs/jobPostings/{job_urn_id}?{urlencode(params, safe='(),')}",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        data = res.json()

        if data and "status" in data and data["status"] != 200:
            self.logger.info("request failed, code {}".format(data["status"]))
            return {}

        return data

    def get_conversation_details(self, profile_urn_id):
        """
        Return the conversation (or "message thread") details for a given [public_profile_id]
        """
        # passing `params` doesn't work properly, think it's to do with List().
        # Might be a bug in `requests`?
        res = self._fetch(
            f"/messaging/conversations?\
            keyVersion=LEGACY_INBOX&q=participants&recipients=List({profile_urn_id})"
        )

        data = res.json()

        item = data["elements"][0]
        item["id"] = get_id_from_urn(item["entityUrn"])

        return item

    def get_conversations(self):
        """
        Return list of conversations the user is in.
        """
        params = {"keyVersion": "LEGACY_INBOX"}

        res = self._fetch(f"/messaging/conversations", params=params)

        return res.json()

    def get_conversation(self, conversation_urn_id):
        """
        Return the full conversation at a given [conversation_urn_id]
        """
        res = self._fetch(f"/messaging/conversations/{conversation_urn_id}/events")

        return res.json()

    def send_message(self, conversation_urn_id=None, recipients=[], message_body=None):
        """
        Send a message to a given conversation. If error, return true.

        Recipients: List of profile urn id's
        """
        params = {"action": "create"}

        if not (conversation_urn_id or recipients) and not message_body:
            return True

        message_event = {
            "eventCreate": {
                "value": {
                    "com.linkedin.voyager.messaging.create.MessageCreate": {
                        "body": message_body,
                        "attachments": [],
                        "attributedBody": {"text": message_body, "attributes": []},
                        "mediaAttachments": [],
                    }
                }
            }
        }

        if conversation_urn_id and not recipients:
            res = self._post(
                f"/messaging/conversations/{conversation_urn_id}/events",
                params=params,
                data=json.dumps(message_event),
            )
        elif recipients and not conversation_urn_id:
            message_event["recipients"] = recipients
            message_event["subtype"] = "MEMBER_TO_MEMBER"
            payload = {
                "keyVersion": "LEGACY_INBOX",
                "conversationCreate": message_event,
            }
            res = self._post(
                f"/messaging/conversations", params=params, data=json.dumps(payload)
            )

        return res.status_code != 201

    def mark_conversation_as_seen(self, conversation_urn_id):
        """
        Send seen to a given conversation. If error, return True.
        """
        payload = json.dumps({"patch": {"$set": {"read": True}}})

        res = self._post(
            f"/messaging/conversations/{conversation_urn_id}", data=payload
        )

        return res.status_code != 200

    def get_user_profile(self):
        """"
        Return current user profile
        """
        sleep(
            random.randint(0, 1)
        )  # sleep a random duration to try and evade suspention

        res = self._fetch(f"/me")

        data = res.json()

        return data

    def get_invitations(self, start=0, limit=3):
        """
        Return list of new invites
        """
        params = {
            "start": start,
            "count": limit,
            "includeInsights": True,
            "q": "receivedInvitation",
        }

        res = self._fetch(
            f"{self.client.API_BASE_URL}/relationships/invitationViews", params=params
        )

        if res.status_code != 200:
            return []

        response_payload = res.json()
        return [element["invitation"] for element in response_payload["elements"]]

    def reply_invitation(
        self, invitation_entity_urn, invitation_shared_secret, action="accept"
    ):
        """
        Reply to an invite, the default is to accept the invitation.
        @Param: invitation_entity_urn: str
        @Param: invitation_shared_secret: str
        @Param: action: "accept" or "ignore"
        Returns True if sucess, False otherwise
        """
        invitation_id = get_id_from_urn(invitation_entity_urn)
        params = {"action": action}
        payload = json.dumps(
            {
                "invitationId": invitation_id,
                "invitationSharedSecret": invitation_shared_secret,
                "isGenericInvitation": False,
            }
        )

        res = self._post(
            f"{self.client.API_BASE_URL}/relationships/invitations/{invitation_id}",
            params=params,
            data=payload,
        )

        return res.status_code == 200

    # def add_connection(self, profile_urn_id):
    #     payload = {
    #         "emberEntityName": "growth/invitation/norm-invitation",
    #         "invitee": {
    #             "com.linkedin.voyager.growth.invitation.InviteeProfile": {
    #                 "profileId": profile_urn_id
    #             }
    #         },
    #     }

    #     print(payload)

    #     res = self._post(
    #         "/growth/normInvitations",
    #         data=payload,
    #         headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
    #     )

    #     return res.status_code != 201

    def remove_connection(self, public_profile_id):
        res = self._post(
            f"/identity/profiles/{public_profile_id}/profileActions?action=disconnect",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )

        return res.status_code != 200

    # TODO doesn't work
    # def view_profile(self, public_profile_id):
    #     res = self._fetch(
    #         f"/identity/profiles/{public_profile_id}/profileView",
    #         headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
    #     )

    #     return res.status_code != 200

    def get_profile_privacy_settings(self, public_profile_id):
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/privacySettings",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})

    def get_profile_member_badges(self, public_profile_id):
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/memberBadges",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})

    def get_profile_network_info(self, public_profile_id):
        res = self._fetch(
            f"/identity/profiles/{public_profile_id}/networkinfo",
            headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
        )
        if res.status_code != 200:
            return {}

        data = res.json()
        return data.get("data", {})