├── media
└── logo_proxycurl_artboard_1.png
├── Dockerfile
├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ ├── pytest.yml
│ └── pylint.yml
├── .gitignore
├── LICENSE
├── inb
├── tests
│ ├── __init__.py
│ ├── test_utils.py
│ ├── test_settings.py
│ ├── test_cookierepo.py
│ └── test_invitation_status.py
├── api
│ ├── utils
│ │ ├── __init__.py
│ │ └── utils.py
│ ├── invitation
│ │ ├── __init__.py
│ │ └── status.py
│ ├── __init__.py
│ ├── exceptions.py
│ ├── settings.py
│ ├── cookierepo.py
│ ├── client.py
│ └── linkedin_api.py
└── inb.py
├── requirements.txt
├── docs
└── styleguide.md
├── DEVELOPERS.md
├── manage.sh
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── README.md
└── .pylintrc
/media/logo_proxycurl_artboard_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshiayush/inb/HEAD/media/logo_proxycurl_artboard_1.png
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.8
2 |
3 | WORKDIR /app
4 |
5 | COPY requirements.txt .
6 | RUN pip install --no-cache-dir -r requirements.txt
7 |
8 | COPY inb /app
9 | ENTRYPOINT [ "python", "inb.py" ]
10 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # These owners will be the default owners for everything in the repo. Unless a later match takes precedence, @joshiayush will be requested for review when someone opens a pull request.
2 | * @joshiayush
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # cache
2 | __pycache__
3 |
4 | # pyinstaller
5 | *.spec
6 | dist/**
7 | build/**
8 |
9 | # virtual environment
10 | lib/**
11 | lib64
12 | include
13 | bin
14 | pyvenv.cfg
15 | share/
16 |
17 | # visual studio code
18 | .vscode
19 |
20 | # log records
21 | logs
22 |
23 | # credentials
24 | credentials.json
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2018 The inb Authors. All Rights Reserved.
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/inb/tests/__init__.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
--------------------------------------------------------------------------------
/inb/api/utils/__init__.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
--------------------------------------------------------------------------------
/inb/api/invitation/__init__.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
--------------------------------------------------------------------------------
/inb/api/__init__.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=missing-module-docstring
2 |
3 | # Copyright 2023 The inb Authors. All Rights Reserved.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | __version__ = '1.0.0'
18 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | | Name | About | Title | Labels | Assignees |
2 | | --------------- | -------------------------------- | ----- | ------ | --------- |
3 | | Feature request | Suggest an idea for this project | Any | Any | Any |
4 |
5 | ## Is your feature request related to a problem? Please describe.
6 |
7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
8 |
9 | ## Describe the solution you'd like
10 |
11 | A clear and succinct description of what you want to happen.
12 |
13 | ## Describe alternatives you've considered
14 |
15 | A clear and succinct description of any alternative solutions or features you've considered (if possible), otherwise delete the
16 | section entirely.
17 |
18 | ## Additional context
19 |
20 | Add any other context or screenshots (if possible) about the feature request here, otherwise delete the section entirely.
21 |
--------------------------------------------------------------------------------
/inb/api/exceptions.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | """LinkedIn API exceptions."""
16 |
17 | LinkedInChallengeException = type('LinkedInChallengeException', (Exception,),
18 | {})
19 |
20 | LinkedInUnauthorizedException = type('LinkedInUnauthorizedException',
21 | (Exception,), {})
22 |
23 | LinkedInSessionExpiredException = type('LinkedInSessionExpiredException',
24 | (Exception,), {})
25 |
26 | LinkedInUnexpectedStatusException = type('LinkedInUnexpectedStatusException',
27 | (Exception,), {})
28 |
--------------------------------------------------------------------------------
/inb/api/utils/utils.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | """Utility module for the LinkedIn API package."""
16 |
17 | import base64
18 | import random
19 |
20 |
21 | def get_id_from_urn(urn: str) -> str:
22 | """Returns the last element from the profile urn string."""
23 | return urn.split(':')[3]
24 |
25 |
26 | def generate_tracking_id() -> str:
27 | """Generates a tracking id to attach to the payload being sent to the voyager
28 | endpoints.
29 |
30 | Returns:
31 | Tracking id for a payload.
32 | """
33 | return str(
34 | base64.b64encode(bytearray([random.randrange(256) for _ in range(16)
35 | ])))[2:-1]
36 |
--------------------------------------------------------------------------------
/.github/workflows/pytest.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | name: Pytest
16 |
17 | on: [push, pull_request]
18 |
19 | jobs:
20 | build:
21 | runs-on: ubuntu-latest
22 |
23 | steps:
24 | - uses: actions/checkout@v2
25 |
26 | - name: Set up Python 3.8
27 | uses: actions/setup-python@v2
28 | with:
29 | python-version: "3.8"
30 |
31 | - name: Install dependencies
32 | run: |
33 | python -m pip install --upgrade pip
34 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
35 |
36 | - name: Test with Pytest
37 | run: |
38 | pytest ${{github.workspace}}/inb/tests/ -v
39 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | altgraph==0.17
2 | astroid==2.9.0
3 | attrs==22.2.0
4 | beautifulsoup4==4.11.2
5 | bs4==0.0.1
6 | CacheControl==0.12.6
7 | cachetools==4.2.2
8 | certifi==2021.5.30
9 | cffi==1.14.5
10 | chardet==4.0.0
11 | click==8.0.3
12 | colorama==0.4.4
13 | commonmark==0.9.1
14 | configparser==5.0.2
15 | crayons==0.4.0
16 | cryptography==3.4.7
17 | exceptiongroup==1.1.0
18 | fernet==1.0.1
19 | grpcio==1.38.1
20 | httplib2==0.19.1
21 | idna==2.10
22 | importlib-metadata==4.5.0
23 | iniconfig==2.0.0
24 | isort==5.10.1
25 | lazy-object-proxy==1.6.0
26 | lxml==4.9.2
27 | mccabe==0.6.1
28 | msgpack==1.0.2
29 | nameparser==1.0.6
30 | packaging==20.9
31 | platformdirs==2.4.0
32 | pluggy==1.0.0
33 | proto-plus==1.18.1
34 | protobuf==3.17.3
35 | psutil==5.9.4
36 | pyaes==1.6.1
37 | pyasn1==0.4.8
38 | pyasn1-modules==0.2.8
39 | pycodestyle==2.7.0
40 | pycparser==2.20
41 | pyfiglet==0.8.post1
42 | Pygments==2.9.0
43 | pyinstaller-hooks-contrib==2021.4
44 | pylint==2.12.2
45 | pyparsing==2.4.7
46 | PySocks==1.7.1
47 | pytest==7.2.2
48 | pytest-mock==3.10.0
49 | python-dateutil==2.8.1
50 | pytz==2021.1
51 | requests==2.25.1
52 | rsa==4.7.2
53 | six==1.16.0
54 | smmap==4.0.0
55 | soupsieve==2.4
56 | toml==0.10.2
57 | tomli==2.0.1
58 | tqdm==4.62.3
59 | typed-ast==1.5.1
60 | typing-extensions==3.10.0.0
61 | uritemplate==3.0.1
62 | urllib3==1.26.5
63 | webdriver-manager==3.2.2
64 | wget==3.2
65 | wrapt==1.13.3
66 | yapf==0.32.0
67 | zipp==3.4.1
68 |
--------------------------------------------------------------------------------
/.github/workflows/pylint.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | name: Pylint
16 |
17 | on: [push, pull_request]
18 |
19 | jobs:
20 | build:
21 | runs-on: ubuntu-latest
22 |
23 | strategy:
24 | matrix:
25 | python-version: ["3.8"]
26 |
27 | steps:
28 | - uses: actions/checkout@v2
29 |
30 | - name: Set up Python ${{ matrix.python-version }}
31 | uses: actions/setup-python@v2
32 | with:
33 | python-version: ${{ matrix.python-version }}
34 |
35 | - name: Install dependencies
36 | run: |
37 | python -m pip install --upgrade pip
38 | pip install pylint
39 |
40 | - name: Analysing the code with pylint
41 | run: |
42 | pylint --rcfile=.pylintrc $(find . -name "*.py" | xargs)
43 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | | Name | About | Labels |
2 | | ---------- | ---------------------------------- | ----------------------------------------------------------------------------- |
3 | | Bug report | Create a report to help us improve | "🛠 goal: fix, 🚦 status: awaiting triage, 💻 aspect: code, 🟧 priority: high" |
4 |
5 | ## Description
6 |
7 | Write a succinct bug description here.
8 |
9 | ## Reproduction
10 |
11 | Provide detailed steps to reproduce the bug.
12 |
13 | 1. Step 1 ...
14 | 2. Step 2 ...
15 | 3. Step 3 ...
16 | 4. See error.
17 |
18 | ## Expectation
19 |
20 | Succinctly describe what you expected to happen.
21 |
22 | ## Screenshots
23 |
24 | Add screenshots (if possible) to show the problem; or delete the section entirely.
25 |
26 | ## Environment
27 |
28 | Please complete this, unless you are certain the problem is not environment specific.
29 |
30 | - Device: (_eg._ laptop; PC)
31 | - OS: (_eg._ iOS 13.5; Fedora 32; Windows; Ubuntu; Kali Linux)
32 | - Interpreter: (_eg._ python; python3)
33 | - Other info: (_eg._ Chrome Driver version; Google Chrome Version)
34 |
35 | ## Additional context
36 |
37 | Add any other context about the problem here; or delete the section entirely.
38 |
39 | ## Resolution
40 |
41 | Replace the [ ] with [x] to check the box.
42 |
43 | - [ ] I would be interested in resolving this bug.
44 |
--------------------------------------------------------------------------------
/inb/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=missing-module-docstring
2 |
3 | # Copyright 2023 The inb Authors. All Rights Reserved.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | from api.utils import utils
18 |
19 |
20 | def test_get_id_from_urn():
21 | urn = 'urn:li:fs_miniProfile:1234567890abcdef'
22 | assert utils.get_id_from_urn(urn) == '1234567890abcdef'
23 |
24 | urn = 'urn:li:fs_miniProfile:abc1234567890def'
25 | assert utils.get_id_from_urn(urn) == 'abc1234567890def'
26 |
27 | urn = 'urn:li:fs_miniProfile:12:34:56:78:90'
28 | assert utils.get_id_from_urn(urn) == '12'
29 |
30 |
31 | def test_generate_tracking_id():
32 | tracking_id = utils.generate_tracking_id()
33 | assert len(tracking_id) == 24
34 | assert isinstance(tracking_id, str)
35 |
36 | for char in tracking_id:
37 | assert char.isalnum() or char in ['+', '/', '=']
38 |
39 | assert tracking_id != utils.generate_tracking_id()
40 |
--------------------------------------------------------------------------------
/inb/api/settings.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | """A configuration file for all the directory paths and logging settings."""
16 |
17 | import os
18 | import pathlib
19 |
20 | USER_HOME_DIR = pathlib.Path.home()
21 | INB_USER_DIR = USER_HOME_DIR / '.inb/'
22 | INB_COOKIE_DIR = INB_USER_DIR / 'cookies/'
23 | INB_LOG_DIR = INB_USER_DIR / 'logs'
24 |
25 | # Variable's value decides whether logging to stream is allowed
26 | # in the entire project.
27 | LOGGING_TO_STREAM_ENABLED = False
28 |
29 | # Create the required directories for storing bot related data.
30 | if not os.path.exists(INB_USER_DIR):
31 | os.makedirs(INB_USER_DIR)
32 | if not os.path.exists(INB_COOKIE_DIR):
33 | os.makedirs(INB_COOKIE_DIR)
34 |
35 | # We want to create the log directory if it does not exists
36 | # otherwise the file handlers for loggers used in other modules
37 | # will complain about its absence.
38 | if not os.path.exists(INB_LOG_DIR):
39 | os.makedirs(INB_LOG_DIR)
40 |
41 | LOG_FORMAT_STR = (
42 | '%(asctime)s:%(name)s:%(levelname)s:%(funcName)s\n%(message)s')
43 |
44 | INB_VERSION = '1.0.0'
45 |
--------------------------------------------------------------------------------
/docs/styleguide.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Style Guide
4 |
5 | Use a consistent coding style provided by [Google Python Style guide](https://google.github.io/styleguide/pyguide.html) which I recommend reading because they also provide information on how to disable [pylint][_pylint] if that's a requirement.
6 |
7 | ## [`yapf`][_yapf]
8 |
9 | In order to be consistent with the rest of the coding style use [`yapf`][_yapf] (A Google Python code formatter).
10 |
11 | ### Installation
12 |
13 | ```shell
14 | python3 -m pip install yapf
15 | ```
16 |
17 | ### Settings
18 |
19 | Use the following settings to configure [`yapf`][_yapf] for your workspace in [vscode](https://code.visualstudio.com/).
20 |
21 | ```json
22 | {
23 | "python.formatting.provider": "yapf",
24 | "python.formatting.yapfArgs": [
25 | "--style={based_on_style: google, column_limit: 80, indent_width: 2}"
26 | ]
27 | }
28 | ```
29 |
30 |
37 |
38 | ## [`pylint`][_pylint]
39 |
40 | In addition to [`yapf`][_yapf] also install [`pylint`][_pylint] to find out bugs in your code, check the quality of your code and more.
41 |
42 | ### Installation
43 |
44 | ```shell
45 | python3 -m pip install pylint
46 | ```
47 |
48 | ### Settings
49 |
50 | ```json
51 | {
52 | "python.linting.enabled": true,
53 | "python.linting.pylintPath": "pylint",
54 | "python.linting.pylintEnabled": true
55 | }
56 | ```
57 |
58 |
65 |
66 |
67 |
68 | [_yapf]: https://github.com/google/yapf
69 | [_pylint]: https://pypi.org/project/pylint/
70 |
71 |
72 |
73 | [back_to_top]: https://img.shields.io/badge/-Back%20to%20top-lightgrey
74 |
--------------------------------------------------------------------------------
/inb/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=missing-module-docstring
2 |
3 | # Copyright 2023 The inb Authors. All Rights Reserved.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | from api import settings
18 |
19 |
20 | def test_user_home_dir():
21 | assert settings.USER_HOME_DIR.is_dir()
22 |
23 |
24 | def test_inb_user_dir():
25 | assert settings.INB_USER_DIR.is_dir()
26 | assert str(settings.INB_USER_DIR).startswith(str(settings.USER_HOME_DIR))
27 |
28 |
29 | def test_inb_cookie_dir():
30 | assert settings.INB_COOKIE_DIR.is_dir()
31 | assert str(settings.INB_COOKIE_DIR).startswith(str(settings.INB_USER_DIR))
32 | assert str(settings.INB_COOKIE_DIR).endswith('/cookies')
33 |
34 |
35 | def test_inb_log_dir():
36 | assert settings.INB_LOG_DIR.is_dir()
37 | assert str(settings.INB_LOG_DIR).startswith(str(settings.USER_HOME_DIR))
38 | assert str(settings.INB_LOG_DIR).endswith('/.inb/logs')
39 |
40 |
41 | def test_logging_to_stream_enabled():
42 | assert not settings.LOGGING_TO_STREAM_ENABLED
43 |
44 |
45 | def test_log_format_str():
46 | assert 'asctime' in settings.LOG_FORMAT_STR
47 | assert 'name' in settings.LOG_FORMAT_STR
48 | assert 'levelname' in settings.LOG_FORMAT_STR
49 | assert 'funcName' in settings.LOG_FORMAT_STR
50 | assert 'message' in settings.LOG_FORMAT_STR
51 |
52 | assert settings.LOG_FORMAT_STR.startswith('%(asctime)s')
53 | assert settings.LOG_FORMAT_STR.endswith('%(message)s')
54 |
55 |
56 | def test_inb_version():
57 | assert settings.INB_VERSION == '1.0.0'
58 |
--------------------------------------------------------------------------------
/DEVELOPERS.md:
--------------------------------------------------------------------------------
1 | # Developing inb
2 |
3 | ## Git Commit Guidelines
4 |
5 | inb follows [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). This leads to **more readable messages**
6 | that are easy to follow when looking through the **project history**. Also, these git commit messages are used to **generate the [changelog](changelog)**.
7 |
8 | ### Commit Message Format
9 |
10 | Each commit message consists of a **header**, a **body** and a **footer**. The header has a special format that includes a **type**, a **scope** and a **subject**:
11 |
12 | ```
13 | ():
14 |
15 |
16 |
17 |
18 | ```
19 |
20 | ### Type
21 |
22 | Must be one of the following:
23 |
24 | - **feat**: A commit of the type `feat` introduces a new feature to the codebase.
25 | - **fix**: A commit of the type `fix` patches a bug in your codebase.
26 | - **docs**: Documentation only changes.
27 | - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc).
28 | - **refactor**: A code change that neither fixes a bug nor adds a feature.
29 | - **perf**: A code change that improves performance.
30 | - **test**: Adding missing or correcting existing tests.
31 | - **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation.
32 | - **build**: Changes to the build.
33 | - **ci**: Changes to the Continuous integration.
34 |
35 | ### Scope
36 |
37 | The scope could be anything specifying place of the commit change. For example `cli`, `database`, `console`, etc...
38 |
39 | You can use `*` when the change affects more than a single scope.
40 |
41 | ### Subject
42 |
43 | The subject contains succinct description of the change:
44 |
45 | - use the imperative, present tense: "change" not "changed" nor "changes".
46 | - don't capitalize first letter.
47 | - no dot (.) at the end.
48 |
49 | ### Body
50 |
51 | Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". The body should include the
52 | motivation for the change and contrast this with previous behavior.
53 |
54 | ### Footer
55 |
56 | The footer should contain any information about **Breaking Changes** and is also the place to [reference GitHub issues that this commit closes](https://help.github.com/articles/closing-issues-via-commit-messages/).
57 |
58 | **Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this.
59 |
60 | **Please read the [docs](https://github.com/joshiayush/inb/tree/master/docs) before developing inb, there's a lot that you need to know before developing inb.**
61 |
62 | [changelog]: CHANGELOG.md
63 |
--------------------------------------------------------------------------------
/inb/tests/test_cookierepo.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=missing-module-docstring, protected-access
2 |
3 | # Copyright 2023 The inb Authors. All Rights Reserved.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | import os
18 | import time
19 | import pickle
20 | import shutil
21 | import tempfile
22 |
23 | from requests import cookies
24 |
25 | from api import (cookierepo, exceptions as linkedin_api_exceptions)
26 |
27 |
28 | class TestCookieRepository:
29 |
30 | def setup_method(self):
31 | self.username = 'ayush854032@gmail.com'
32 | self.cookie_jar = cookies.RequestsCookieJar()
33 | self.cookie_jar['cookie1'] = 'value1'
34 | self.cookie_jar['cookie2'] = 'value2'
35 |
36 | self.tmp_dir = tempfile.mkdtemp()
37 | self.cookie_repo = cookierepo.CookieRepository(self.username,
38 | self.cookie_jar,
39 | self.tmp_dir)
40 |
41 | def teardown_method(self):
42 | shutil.rmtree(self.tmp_dir)
43 |
44 | def test_get_cookie_dir(self):
45 | assert self.cookie_repo.get_cookie_dir() == self.tmp_dir
46 |
47 | def test__get_cookies_jar_file_path(self):
48 | expected_path = os.path.join(self.tmp_dir, self.username)
49 | assert os.fspath(
50 | self.cookie_repo._get_cookies_jar_file_path()) == expected_path
51 |
52 | def test_save(self):
53 | cookie_jar_file_path = self.cookie_repo._get_cookies_jar_file_path()
54 | assert not os.path.exists(cookie_jar_file_path)
55 | self.cookie_repo.save()
56 | assert os.path.exists(cookie_jar_file_path)
57 |
58 | with open(cookie_jar_file_path, 'rb') as jar_file:
59 | loaded_cookie_jar = pickle.load(jar_file)
60 | assert loaded_cookie_jar == self.cookie_jar
61 |
62 | def test_get_cookies(self):
63 | cookie_jar_file_path = self.cookie_repo._get_cookies_jar_file_path()
64 | with open(cookie_jar_file_path, 'wb') as jar_file:
65 | pickle.dump(self.cookie_jar, jar_file)
66 |
67 | loaded_cookie_jar = self.cookie_repo.get_cookies()
68 | assert loaded_cookie_jar == self.cookie_jar
69 |
70 | # check expiration
71 | self.cookie_jar['JSESSIONID'] = '123456'
72 | expired_cookie = cookies.RequestsCookieJar()
73 | expired_cookie.set_cookie(
74 | cookies.create_cookie(name='JSESSIONID',
75 | value='9068257311',
76 | expires=time.time() + 60))
77 | self.cookie_jar.update(expired_cookie)
78 | with open(cookie_jar_file_path, 'wb') as jar_file:
79 | pickle.dump(self.cookie_jar, jar_file)
80 | try:
81 | self.cookie_repo.get_cookies()
82 | assert False, 'Expected exception not raised'
83 | except linkedin_api_exceptions.LinkedInSessionExpiredException:
84 | assert True
85 |
--------------------------------------------------------------------------------
/inb/api/cookierepo.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | """Authentication Cookie-Repository Management Package."""
16 |
17 | import os
18 | import time
19 | import pickle
20 | import pathlib
21 |
22 | from requests import cookies
23 |
24 | from api import settings, exceptions as linkedin_api_exceptions
25 |
26 |
27 | class CookieRepository(object):
28 | """Creates a 'Cookie Repository' in the given directory."""
29 |
30 | def __init__(self, username: str, cookies_: cookies.RequestsCookieJar,
31 | cookie_dir: str) -> None:
32 | self.cookies = cookies_
33 | self.username = username
34 |
35 | if cookie_dir is None:
36 | cookie_dir = settings.INB_COOKIE_DIR
37 | self.cookie_dir = pathlib.Path(cookie_dir)
38 |
39 | def get_cookie_dir(self) -> str:
40 | """Returns a 'fs' compatible path of the cookie directory."""
41 | return os.fspath(self.cookie_dir)
42 |
43 | def _get_cookies_jar_file_path(self) -> pathlib.Path:
44 | """Returns the cookies jar file path that is generated after combining the
45 | given cookie directory with the label 'username'.
46 |
47 | Returns:
48 | Cookies jar file path.
49 | """
50 | return self.cookie_dir / self.username
51 |
52 | def save(self) -> None:
53 | """Saves the constructor initialized cookies in the constructor initialized
54 | cookies directory path.
55 | """
56 | if not os.path.exists(os.fspath(self.cookie_dir)):
57 | os.makedirs(os.fspath(self.cookie_dir))
58 |
59 | # Every user has a Cookie Repository in the 'cookies directory' with a file
60 | # name equal to their 'username'.
61 | cookie_jar_file_path = self._get_cookies_jar_file_path()
62 | with open(cookie_jar_file_path, 'wb') as jar_file:
63 | pickle.dump(self.cookies, jar_file)
64 |
65 | def get_cookies(self) -> cookies.RequestsCookieJar:
66 | """Returns the 'RequestCookieJar' instance of the cookies saved in the
67 | cookies directory for the instantiated username.
68 |
69 | Returns:
70 | 'cookies.RequestsCookieJar' instance of user cookies.
71 | """
72 | # Every user has a Cookie Repository in the 'cookies directory' with a file
73 | # name equal to their 'username'.
74 | cookie_jar_file_path = self._get_cookies_jar_file_path()
75 | if not os.path.exists(cookie_jar_file_path):
76 | return None
77 |
78 | cookies_ = None
79 | with open(cookie_jar_file_path, 'rb') as jar_file:
80 | cookies_ = pickle.load(jar_file)
81 |
82 | # We still need to check if the cookies have expired.
83 | for cookie in cookies_:
84 | if cookie.name == 'JSESSIONID' and cookie.value:
85 | if cookie.expires and cookie.expires > time.time():
86 | raise linkedin_api_exceptions.LinkedInSessionExpiredException()
87 | break
88 | return cookies_
89 |
--------------------------------------------------------------------------------
/manage.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Copyright 2023 The inb Authors. All Rights Reserved.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | if [ $# -eq 0 ]; then
18 | exit 1
19 | fi
20 |
21 | project_root_dir=$(pwd)
22 |
23 | #
24 | # function _dcache deletes __pycache__ folders floating around python modules
25 | #
26 | function _dcache() {
27 | find "$project_root_dir/inb" -name "__pycache__" >pycache
28 |
29 | while IFS= read -r cache_file; do
30 | rm -r $cache_file
31 | done /dev/null
57 | fi
58 | fi
59 | }
60 |
61 | #
62 | # function _parse_args parses the arguments given to the program
63 | #
64 | _parse_args() {
65 | arg=
66 | mutually_exclusive_group_found=false
67 | while [ "${1:-}" != "" ]; do
68 | case "$1" in
69 | "-i" | "--install")
70 | if [ $mutually_exclusive_group_found = false ]; then
71 | arg="install"
72 | mutually_exclusive_group_found=true
73 | fi
74 | ;;
75 | "-d" | "--dcache")
76 | if [ $mutually_exclusive_group_found = false ]; then
77 | arg="dcache"
78 | mutually_exclusive_group_found=true
79 | fi
80 | ;;
81 | "-l" | "--line")
82 | if [ $mutually_exclusive_group_found = false ]; then
83 | arg="line"
84 | mutually_exclusive_group_found=true
85 | fi
86 | ;;
87 | "-v" | "--verbose")
88 | arg="$arg verbose"
89 | ;;
90 | esac
91 | shift
92 | done
93 | echo $arg
94 | }
95 |
96 | #
97 | # entry point
98 | #
99 | function main() {
100 | arg=$(_parse_args $@)
101 | if [[ $arg == install* ]]; then
102 | if [[ $arg =~ "verbose" ]]; then
103 | _install 0
104 | else
105 | _install
106 | fi
107 | elif [[ $arg == "dcache" ]]; then
108 | _dcache
109 | elif [[ $arg == "line" ]]; then
110 | _get_code_lines
111 | fi
112 | }
113 |
114 | # call main function with the arguments given
115 | main $@
116 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to become a contributor and submit your own code
2 |
3 | ## Contributing A Patch
4 |
5 | 1. Submit an issue describing your proposed change to the [issue tracker](https://github.com/joshiayush/inb/issues).
6 | 2. Please don't mix more than one logical change per submittal, because it makes the history hard to follow. If you want to make a change that doesn't have a corresponding issue in the issue tracker, please create one.
7 | 3. Also, coordinate with team members that are listed on the issue in question. This ensures that work isn't being duplicated and communicating your plan early also generally leads to better patches.
8 | 4. Fork the repo, develop and test your code changes.
9 | 5. Ensure that your code adheres to the existing style. See [.pylintrc](https://github.com/joshiayush/inb/blob/master/.pylintrc) in the root directory.
10 | 6. Ensure that your code has an appropriate set of unit tests which all pass.
11 | 7. Submit a pull request.
12 |
13 | ## Style
14 |
15 | To keep the source consistent, readable, diffable and easy to merge, we use a fairly rigid coding style, as defined by the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html)
16 |
17 | ## Requirements for Contributors
18 |
19 | If you plan to contribute a patch, you need:
20 |
21 | - `Python 3.7.12` or `Python 3.7.x` or higher.
22 | - `virtualenv 20.7.2` or `virtualenv 20.7.x` or higher.
23 |
24 | ## Testing inb
25 |
26 | We use [unittest][_unittest] framework for testing, it also comes with a built-in test runner. If you have a python version that is greater than `3.7` then [unittest][_unittest] is already present in your system.
27 |
28 | Otherwise install it using the following command,
29 |
30 | ```shell
31 | pip install unittest
32 | ```
33 |
34 | **Execute all tests written so far,**
35 |
36 | ```shell
37 | python3 inb/test.py
38 | ```
39 |
40 | **Run a specific test case,**
41 |
42 | ```shell
43 | python3 inb/test.py TestCaseName[.test_suite]
44 | ```
45 |
46 | ## Commit
47 |
48 | Before commiting please make sure that you have read the [`DEVELOPERS.md`](https://github.com/joshiayush/inb/blob/master/DEVELOPERS.md) file.
49 |
50 | ## License
51 |
52 | By contributing, you agree that your contributions will be licensed under its MIT License. Include the following license at the beginning of every file.
53 |
54 | ```python
55 | # MIT License
56 | #
57 | # Copyright (c) 2019 Creative Commons
58 | #
59 | # Permission is hereby granted, free of charge, to any person obtaining a
60 | # copy of this software and associated documentation files (the "Software"),
61 | # to deal in the Software without restriction, including without limitation
62 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
63 | # and/or sell copies of the Software, and to permit persons to whom the
64 | # Software is furnished to do so, subject to the following conditions
65 | #
66 | # The above copyright notice and this permission notice shall be included
67 | # in all copies or substantial portions of the Software.
68 | #
69 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
70 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
71 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
72 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
73 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
74 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
75 | # THE SOFTWARE.
76 | ```
77 |
78 |
79 |
80 | [_unittest]: https://docs.python.org/3/library/unittest.html
81 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, caste, color, religion, or sexual
10 | identity and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the overall
26 | community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or advances of
31 | any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email address,
35 | without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | ayush854032@gmail.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
19 |
20 | **inb** is an automation tool for LinkedIn that allows users to automate various tasks, such as sending connection requests, messaging connections, and endorsing skills. With **inb**, users can save time and streamline their LinkedIn outreach efforts.
21 |
22 | The tool is written in Python and uses the **LinkedIn Voyager API** to interact with LinkedIn.
23 |
24 | **inb** is designed for professionals who want to expand their network and increase their visibility on LinkedIn. It can be used for personal or business purposes, and is ideal for individuals who want to grow their network without spending hours manually sending connection requests and messages.
25 |
26 | The tool is open source and available on GitHub, so users can contribute to the development of the project and customize it to their specific needs. To get started, simply download the tool from GitHub and follow the instructions in the **`README`** file.
27 |
28 | > No **"official"** API access required - Just use a valid LinkedIn account!
29 |
30 |
37 |
38 | ## Sponsor
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | Scrape public LinkedIn profile data at scale with [Proxycurl APIs](https://nubela.co/proxycurl?utm_campaign=influencer_marketing&utm_source=github&utm_medium=social&utm_content=ayush_joshi_inb_repo).
47 |
48 | - Scraping Public profiles are battle tested in court in HiQ VS LinkedIn case.
49 | - GDPR, CCPA, SOC2 compliant
50 | - High rate Limit - 300 requests/minute
51 | - Fast APIs respond in ~2s
52 | - Fresh data - 88% of data is scraped real-time, other 12% are not older than 29 days
53 | - High accuracy
54 | - Tons of data points returned per profile
55 |
56 | Built for developers, by developers.
57 |
58 |
65 |
66 | ## Clone
67 |
68 | Clone the repository.
69 |
70 | ```shell
71 | git clone https://github.com/joshiayush/inb.git
72 | ```
73 |
74 |
81 |
82 | ## Docker
83 |
84 | To use the app with docker, you can use the following command, to build the app:
85 |
86 | ```shel
87 | docker build -t inb .
88 | ```
89 |
90 | Then, this one to run it:
91 |
92 | ```shell
93 | docker run -it inb search --email username@service.domain --password xxx-xxx-xxx --keyword 'Software Engineer'
94 | ```
95 |
96 |
103 |
104 | ## Installation
105 |
106 | Next step is to install all the dependencies required for project **inb** listed in the `requirements.txt` file.
107 |
108 | ```shell
109 | python3 -m pip install [-r] requirements.txt
110 | ```
111 |
112 |
119 |
120 | ## Usage
121 |
122 | **A quick usage guide on `search`:**
123 |
124 | Usage: `inb.py search [OPTIONS]`, searches for the specific keyword given and sends invitation to them. To send invitations to people on LinkedIn you could use:
125 |
126 | ```shell
127 | ./inb/inb.py search --email username@service.domain --password xxx-xxx-xxx --keyword 'Software Engineer'
128 | ```
129 |
130 | [`inb`][_inb] supports cookie based authentication - use `--refresh-cookies` in case you encounter error `LinkedInSessionExpiredException`.
131 |
132 | ```shell
133 | ./inb/inb.py search --email username@service.domain --password xxx-xxx-xxx --keyword 'Software developer' --refresh-cookies
134 | ```
135 |
136 | Also, for security purpose you can omit the `--pasword` argument over the command-line and later on executing the tool you'll be prompted to enter your password which will be hidden even after pressing keystrokes.
137 |
138 | ```shell
139 | ./inb/inb.py search --email username@service.domain --keyword 'Software developer' --refresh-cookies
140 | ```
141 |
142 | And the best part is here, you can send connection request and not follow the LinkedIn profile. It will prevent your LinkedIn feed from going terrible.
143 |
144 | ```shell
145 | ./inb/inb.py search --email username@service.domain --keyword 'Software developer' --refresh-cookies --nofollow
146 | ```
147 |
148 | > **Any problems encountered in non-linux environment should be reported immediately before passing comments on the portability of this tool as I've only built and tested it on Linux!**
149 |
150 |
157 |
158 | ## Contribution
159 |
160 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement", "bug", or "documentation". Don't forget to give the project a star! Thanks again!
161 |
162 | Project [**inb**][_inb] is hosted on [GitHub][_github]. If you want to contribute changes please make sure to read the [`CONTRIBUTING.md`][_inb_contrib_f] file. You can also contribute changes to the [`CONTRIBUTING.md`][_inb_contrib_f] file itself.
163 |
164 |
171 |
172 |
173 |
174 | [_github]: https://www.github.com
175 | [_inb]: https://www.github.com/joshiayush/inb
176 |
177 |
178 |
179 | [back_to_top]: https://img.shields.io/badge/-Back%20to%20top-lightgrey
180 |
181 |
182 |
183 | [_inb_contrib_f]: https://github.com/joshiayush/inb/blob/master/CONTRIBUTING.md
184 |
--------------------------------------------------------------------------------
/inb/api/client.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Client simulator for Voyager API."""
15 |
16 | import sys
17 | import bs4
18 | import json
19 | import logging
20 | import requests
21 |
22 | from requests import cookies
23 |
24 | from api import (exceptions as linkedin_api_exceptions, cookierepo, settings)
25 |
26 | logger = logging.getLogger(__name__)
27 | logger.setLevel(logging.DEBUG)
28 |
29 | file_handler = logging.FileHandler(settings.INB_LOG_DIR / __name__, mode='a')
30 | file_handler.setFormatter(logging.Formatter(settings.LOG_FORMAT_STR))
31 |
32 | if settings.LOGGING_TO_STREAM_ENABLED:
33 | stream_handler = logging.StreamHandler(sys.stderr)
34 | stream_handler.setFormatter(logging.Formatter(settings.LOG_FORMAT_STR))
35 | logger.addHandler(stream_handler)
36 |
37 | logger.addHandler(file_handler)
38 |
39 |
40 | class Client(object):
41 | """Client simulator for Voyager API."""
42 |
43 | LINKEDIN_BASE_URL = 'https://www.linkedin.com'
44 | LINKEDIN_AUTH_URL = f'{LINKEDIN_BASE_URL}/uas/authenticate'
45 | VOYAGER_API_BASE_URL = f'{LINKEDIN_BASE_URL}/voyager/api'
46 |
47 | API_REQUEST_HEADERS = {
48 | 'user-agent': ('Mozilla/5.0 (X11; Linux x86_64)'
49 | ' AppleWebKit/537.36 (KHTML, like Gecko)'
50 | ' Chrome/110.0.0.0 Safari/537.36'),
51 | 'accept-language': 'en-AU,en-GB;q=0.9,en-US;q=0.8,en;q=0.7',
52 | 'x-li-lang': 'en_US',
53 | 'x-restli-protocol-version': '2.0.0'
54 | }
55 |
56 | API_AUTH_REQUEST_HEADERS = {
57 | 'X-Li-User-Agent':
58 | 'LIAuthLibrary:3.2.4 com.linkedin.LinkedIn:8.8.1 iPhone:8.3',
59 | 'User-Agent':
60 | 'LinkedIn/8.8.1 CFNetwork/711.3.18 Darwin/14.0.0',
61 | 'X-User-Language':
62 | 'en',
63 | 'X-User-Locale':
64 | 'en_US',
65 | 'Accept-Language':
66 | 'en-us',
67 | }
68 |
69 | def __init__(self,
70 | *,
71 | debug: bool = False,
72 | refresh_cookies: bool = False,
73 | proxies: dict = None,
74 | cookies_dir: str = None) -> None:
75 | self.session = requests.session()
76 |
77 | if not proxies:
78 | proxies = {}
79 | self.session.proxies.update(proxies)
80 | self.session.headers.update(Client.API_REQUEST_HEADERS)
81 |
82 | self.logger = logger
83 | self.proxies = proxies
84 | self.metadata = {}
85 |
86 | self._cookies_dir = cookies_dir
87 | self._use_cookie_cache = not refresh_cookies
88 |
89 | self._logger = logger
90 | if not debug:
91 | self._logger.setLevel(logging.CRITICAL)
92 |
93 | def _set_session_cookies(self, cookies_: cookies.RequestsCookieJar) -> None:
94 | """Sets the session cookies for authentication with voyager API."""
95 | self.session.cookies = cookies_
96 | self.session.headers['csrf-token'] = self.session.cookies.get(
97 | 'JSESSIONID').strip('"')
98 |
99 | def _request_session_cookies(self) -> cookies.RequestsCookieJar:
100 | """Request cookies for the established session."""
101 | return requests.get(Client.LINKEDIN_AUTH_URL,
102 | headers=Client.API_AUTH_REQUEST_HEADERS,
103 | proxies=self.proxies,
104 | timeout=60.0).cookies
105 |
106 | def _fetch_metadata(self) -> None:
107 | result_ = requests.get(Client.LINKEDIN_BASE_URL,
108 | cookies=self.session.cookies,
109 | headers=Client.API_AUTH_REQUEST_HEADERS,
110 | proxies=self.proxies,
111 | timeout=60.0)
112 | soup_ = bs4.BeautifulSoup(result_.text, 'lxml')
113 |
114 | if client_application_instance_raw := soup_.find(
115 | 'meta', attrs={'name': 'applicationInstance'}):
116 | client_application_instance = json.loads(
117 | client_application_instance_raw.attrs.get('content') or {})
118 | self.metadata['clientApplicationInstance'] = client_application_instance
119 |
120 | if client_page_instance_id_raw := soup_.find(
121 | 'meta', attrs={'name': 'clientPageInstanceId'}):
122 | client_page_instance_id = client_page_instance_id_raw.attrs.get(
123 | 'content') or {}
124 | self.metadata['clientPageInstanceId'] = client_page_instance_id
125 |
126 | def _fallback_authentication(self, username: str, password: str) -> None:
127 | self._set_session_cookies(self._request_session_cookies())
128 |
129 | payload_ = {
130 | 'session_key': username,
131 | 'session_password': password,
132 | 'JSESSIONID': self.session.cookies['JSESSIONID']
133 | }
134 | result_ = requests.post(Client.LINKEDIN_AUTH_URL,
135 | data=payload_,
136 | cookies=self.session.cookies,
137 | headers=Client.API_AUTH_REQUEST_HEADERS,
138 | proxies=self.proxies,
139 | timeout=60.0)
140 | data_ = result_.json()
141 | if data_ and data_['login_result'] != 'PASS':
142 | raise linkedin_api_exceptions.LinkedInChallengeException(
143 | data_['login_result'])
144 | if result_.status_code == 401:
145 | raise linkedin_api_exceptions.LinkedInUnauthorizedException()
146 | if result_.status_code != 200:
147 | raise linkedin_api_exceptions.LinkedInUnexpectedStatusException(
148 | f'Received "{result_.status_code}" as a status code for'
149 | f' payload "{repr(payload_)}"')
150 |
151 | self._set_session_cookies(result_.cookies)
152 | self._cookie_repository.cookies = result_.cookies
153 | self._cookie_repository.username = username
154 | self._cookie_repository.save()
155 |
156 | def authenticate(self, username: str, password: str) -> None:
157 | self._cookie_repository = cookierepo.CookieRepository(
158 | username=username, cookies_=None, cookie_dir=self._cookies_dir)
159 | if self._use_cookie_cache:
160 | self._logger.debug('Attempting to use cached cookies at %s',
161 | self._cookie_repository.get_cookie_dir())
162 | cookies_ = self._cookie_repository.get_cookies()
163 | if cookies_:
164 | self._set_session_cookies(cookies_)
165 | self._fetch_metadata()
166 | return
167 |
168 | self._logger.warning('Empty cookie repository at %s',
169 | self._cookie_repository.get_cookie_dir())
170 | self._logger.info(
171 | 'Falling back to authentication using (username="%s", password="%s")',
172 | username, '*' * len(password))
173 |
174 | self._fallback_authentication(username, password)
175 | self._fetch_metadata()
176 |
--------------------------------------------------------------------------------
/inb/api/invitation/status.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | """Invitation module to send invitation status to console."""
16 |
17 | from __future__ import annotations
18 |
19 | import sys
20 | import time
21 | import click
22 |
23 | _SUCCESS_RATE = 0
24 | _FAILURE_RATE = 0
25 |
26 | _SENT_STATUS_SYMBOL = '✔'
27 | _FAILED_STATUS_SYMBOL = '✘'
28 |
29 | _SEARCH_INVITATION_STATUS_TEMPL = """ {{status}} {{name}}
30 | {{occupation}}
31 | {{location}}
32 | Success: {{success}} Failure: {{failure}} Elapsed time: {{elapsed_time}}
33 | """
34 |
35 |
36 | class Person:
37 | """A separate type for the LinkedIn user."""
38 |
39 | def __init__(
40 | self,
41 | *,
42 | name: str,
43 | occupation: str,
44 | location: str,
45 | profileid: str,
46 | profileurl: str,
47 | ):
48 | self.name = name
49 | self.occupation = occupation
50 | self.location = location
51 | self.profileid = profileid
52 | self.profileurl = profileurl
53 |
54 |
55 | class Invitation(object):
56 | """Invitation API to set invitation status on console.
57 |
58 | Use function `display_invitation_status_on_console()` to print out
59 | invitation status on console for the given `Person` instance.
60 | """
61 |
62 | _SLEEP_TIME_AFTER_LOGGING = 0.18
63 |
64 | def set_invitation_fields(self, name: str, occupation: str, location: str,
65 | profileid: str, profileurl: str, status: str,
66 | elapsed_time: int) -> None:
67 | """Sets the invitation status fields from the given `Person` instance.
68 |
69 | Note, setting the success rate of the instance will not reflect in the
70 | actual success and failure counts for that you have to directly update
71 | the values of the global `_SUCCESS_RATE` and `_FAILURE_RATE` variables.
72 |
73 | Args:
74 | name: Name of the person.
75 | occupation: Occupation of the person.
76 | location: Location of the person.
77 | profileid: Profile ID of the person.
78 | profileurl: Profile URL of the person.
79 | status: Status of this invitation.
80 | elapsed_time: Time elapsed till now.
81 | """
82 | self._name = name
83 | self._occupation = occupation
84 | self._location = location
85 | self._profileid = profileid
86 | self._profileurl = profileurl
87 |
88 | self._success_rate = 0
89 | self._failure_rate = 0
90 |
91 | if status == 'sent':
92 | self._status = _SENT_STATUS_SYMBOL
93 | global _SUCCESS_RATE
94 | _SUCCESS_RATE += 1
95 | self._success_rate = _SUCCESS_RATE
96 | elif status == 'failed':
97 | self._status = _FAILED_STATUS_SYMBOL
98 | global _FAILURE_RATE
99 | _FAILURE_RATE += 1
100 | self._failure_rate = _FAILURE_RATE
101 |
102 | # Try to trim the elapsed time to upto 4 digits.
103 | try:
104 | self._elapsed_time = str(elapsed_time)[0:5] + 's'
105 | except IndexError:
106 | self._elapsed_time = elapsed_time
107 |
108 | @staticmethod
109 | def _replace_template_var_with_template_value(
110 | message_template: str, replace_template_var_with: list[tuple]) -> str:
111 | """Replaces all the template variables in `_SEARCH_INVITATION_STATUS_TEMPL`
112 | with their actual values.
113 |
114 | Args:
115 | message_template: The template message.
116 | replace_template_var_with: A list containing the replacement values for
117 | the template variables.
118 | """
119 | for replace_template_var_with_value_pair in replace_template_var_with:
120 | template_var = replace_template_var_with_value_pair[0]
121 | template_value = replace_template_var_with_value_pair[1]
122 |
123 | if template_value is not None:
124 | message_template = message_template.replace(
125 | template_var, template_value)
126 | else:
127 | message_template = message_template.replace(template_var, 'NaN')
128 | return message_template
129 |
130 | def _fill_search_message_template(self) -> str:
131 | """Fills the `_SEARCH_INVITATION_STATUS_TEMPL` with the properties inside
132 | `Person` instance.
133 | """
134 | replace_template_var_with = [('{{status}}', self._status),
135 | ('{{name}}', self._name),
136 | ('{{occupation}}', self._occupation),
137 | ('{{location}}', self._location),
138 | ('{{success}}', str(self._success_rate)),
139 | ('{{failure}}', str(self._failure_rate)),
140 | ('{{elapsed_time}}', str(self._elapsed_time))]
141 | return self._replace_template_var_with_template_value(
142 | _SEARCH_INVITATION_STATUS_TEMPL, replace_template_var_with)
143 |
144 | def _send_status_to_console(self, sleep: bool = True) -> None:
145 | """Private method to echo the invitation status on the console.
146 |
147 | This method fills the `_SEARCH_INVITATION_STATUS_TEMPL` with the given
148 | `Person` information and logs them off to the console using the `click`
149 | library which also takes care of the internationalization for us. Also,
150 | the library automatically detects the colors being used in the terminal
151 | and colorifies the given message accordingly.
152 |
153 | Args:
154 | sleep: Whether to sleep for `_SLEEP_TIME_AFTER_LOGGING` after logging.
155 | """
156 | click.echo('', sys.stdout, True, True)
157 | click.echo(self._fill_search_message_template(), sys.stdout, True, True)
158 | click.echo('', sys.stdout, True, True)
159 | if sleep is True:
160 | time.sleep(self._SLEEP_TIME_AFTER_LOGGING)
161 |
162 | def display_invitation_status_on_console(
163 | self,
164 | person: Person,
165 | status: str, # pylint: disable=redefined-outer-name
166 | start_time: int) -> None:
167 | """Display the invitation status on the console for the given `Person`
168 | instance.
169 |
170 | The `set_invitation_fields()` method must be called before
171 | `_send_status_to_console()` method otherwise it will result in an error
172 | due to unknown definition of `self` attributes.
173 |
174 | Args:
175 | person: Person instance with meta information.
176 | status: Status of the current invitation.
177 | start_time: Clock in time of the invitation process.
178 | """
179 | self.set_invitation_fields(name=person.name,
180 | occupation=person.occupation,
181 | location=person.location,
182 | profileid=person.profileid,
183 | profileurl=person.profileurl,
184 | status=status,
185 | elapsed_time=time.time() - start_time)
186 | self._send_status_to_console()
187 |
--------------------------------------------------------------------------------
/inb/tests/test_invitation_status.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=missing-module-docstring, redefined-outer-name, protected-access
2 |
3 | # Copyright 2023 The inb Authors. All Rights Reserved.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | import sys
18 | import pytest
19 |
20 | from unittest import mock
21 |
22 | from api.invitation import status
23 |
24 |
25 | @pytest.fixture()
26 | def person():
27 | return status.Person(name='John Smith',
28 | occupation='Software Engineer',
29 | location='San Francisco, CA',
30 | profileid='john-smith',
31 | profileurl='https://www.linkedin.com/in/john-smith')
32 |
33 |
34 | @pytest.fixture()
35 | def invitation():
36 | return status.Invitation()
37 |
38 |
39 | def test_person_properties(person):
40 | assert person.name == 'John Smith'
41 | assert person.occupation == 'Software Engineer'
42 | assert person.location == 'San Francisco, CA'
43 | assert person.profileid == 'john-smith'
44 | assert person.profileurl == 'https://www.linkedin.com/in/john-smith'
45 |
46 |
47 | def test_invitation_set_invitation_fields_success(invitation):
48 | invitation.set_invitation_fields(
49 | name='John Smith',
50 | occupation='Software Engineer',
51 | location='San Francisco, CA',
52 | profileid='john-smith',
53 | profileurl='https://www.linkedin.com/in/john-smith',
54 | status='sent',
55 | elapsed_time=10.0)
56 |
57 | assert invitation._name == 'John Smith'
58 | assert invitation._occupation == 'Software Engineer'
59 | assert invitation._location == 'San Francisco, CA'
60 | assert invitation._profileid == 'john-smith'
61 | assert invitation._profileurl == 'https://www.linkedin.com/in/john-smith'
62 | assert invitation._success_rate == 1
63 | assert invitation._failure_rate == 0
64 | assert invitation._status == '✔'
65 | assert invitation._elapsed_time == '10.0s'
66 |
67 |
68 | def test_invitation_set_invitation_fields_failure(invitation):
69 | invitation.set_invitation_fields(
70 | name='John Smith',
71 | occupation='Software Engineer',
72 | location='San Francisco, CA',
73 | profileid='john-smith',
74 | profileurl='https://www.linkedin.com/in/john-smith',
75 | status='failed',
76 | elapsed_time=10.0)
77 |
78 | assert invitation._name == 'John Smith'
79 | assert invitation._occupation == 'Software Engineer'
80 | assert invitation._location == 'San Francisco, CA'
81 | assert invitation._profileid == 'john-smith'
82 | assert invitation._profileurl == 'https://www.linkedin.com/in/john-smith'
83 | assert invitation._success_rate == 0
84 | assert invitation._failure_rate == 1
85 | assert invitation._status == '✘'
86 | assert invitation._elapsed_time == '10.0s'
87 |
88 |
89 | def test_invitation_send_status_to_console(invitation):
90 | status._SUCCESS_RATE = 0
91 | status._FAILURE_RATE = 0
92 |
93 | invitation.set_invitation_fields(
94 | name='John Smith',
95 | occupation='Software Engineer',
96 | location='San Francisco, CA',
97 | profileid='john-smith',
98 | profileurl='https://www.linkedin.com/in/john-smith',
99 | status='sent',
100 | elapsed_time=10.0)
101 |
102 | expected_output = (' ✔ John Smith\n'
103 | ' Software Engineer\n'
104 | ' San Francisco, CA\n'
105 | ' Success: 1 Failure: 0 Elapsed time: 10.0s\n')
106 |
107 | with mock.patch('click.echo') as mk_click_echo:
108 | invitation._send_status_to_console()
109 | mk_click_echo.assert_has_calls([
110 | mock.call('', sys.stdout, True, True),
111 | mock.call(expected_output, sys.stdout, True, True),
112 | mock.call('', sys.stdout, True, True)
113 | ])
114 |
115 |
116 | def test_invitation_send_status_to_console_with_empty_values(invitation):
117 | status._SUCCESS_RATE = 0
118 | status._FAILURE_RATE = 0
119 |
120 | invitation.set_invitation_fields(
121 | name='John Smith',
122 | occupation=None,
123 | location=None,
124 | profileid='john-smith',
125 | profileurl='https://www.linkedin.com/in/john-smith',
126 | status='sent',
127 | elapsed_time=10.0)
128 |
129 | expected_output = (' ✔ John Smith\n'
130 | ' NaN\n'
131 | ' NaN\n'
132 | ' Success: 1 Failure: 0 Elapsed time: 10.0s\n')
133 |
134 | with mock.patch('click.echo') as mk_click_echo:
135 | invitation._send_status_to_console()
136 | mk_click_echo.assert_has_calls([
137 | mock.call('', sys.stdout, True, True),
138 | mock.call(expected_output, sys.stdout, True, True),
139 | mock.call('', sys.stdout, True, True)
140 | ])
141 |
142 |
143 | @pytest.fixture
144 | def mock_sleep():
145 | with mock.patch('time.sleep') as mk_sleep:
146 | mk_sleep.time.return_value = 100.0
147 | yield mk_sleep
148 |
149 |
150 | def test_invitation_display_invitation_status_on_console(
151 | invitation, mock_sleep):
152 | # Test invitation sent
153 | with mock.patch('click.echo') as mk_echo:
154 | status._SUCCESS_RATE = 0
155 | status._FAILURE_RATE = 0
156 | invitation.set_invitation_fields(
157 | name='John Smith',
158 | occupation='Software Engineer',
159 | location='San Francisco',
160 | profileid='john-smit',
161 | profileurl='https://linkedin.com/john-smith',
162 | status='sent',
163 | elapsed_time=10.0)
164 | invitation._send_status_to_console()
165 | expected_output = (' ✔ John Smith\n'
166 | ' Software Engineer\n'
167 | ' San Francisco\n'
168 | ' Success: 1 Failure: 0 Elapsed time: 10.0s\n')
169 | mk_echo.assert_has_calls([
170 | mock.call('', sys.stdout, True, True),
171 | mock.call(expected_output, sys.stdout, True, True),
172 | mock.call('', sys.stdout, True, True)
173 | ])
174 | mock_sleep.assert_called_with(status.Invitation._SLEEP_TIME_AFTER_LOGGING)
175 |
176 | # Test invitation failed
177 | with mock.patch('click.echo') as mk_echo:
178 | status._SUCCESS_RATE = 0
179 | status._FAILURE_RATE = 0
180 | invitation.set_invitation_fields(
181 | name='John Smith',
182 | occupation='Software Engineer',
183 | location='San Francisco',
184 | profileid='john-smit',
185 | profileurl='https://linkedin.com/john-smith',
186 | status='failed',
187 | elapsed_time=10.0)
188 | invitation._send_status_to_console()
189 | expected_output = (' ✘ John Smith\n'
190 | ' Software Engineer\n'
191 | ' San Francisco\n'
192 | ' Success: 0 Failure: 1 Elapsed time: 10.0s\n')
193 | mk_echo.assert_has_calls([
194 | mock.call('', sys.stdout, True, True),
195 | mock.call(expected_output, sys.stdout, True, True),
196 | mock.call('', sys.stdout, True, True)
197 | ])
198 | mock_sleep.assert_called_with(status.Invitation._SLEEP_TIME_AFTER_LOGGING)
199 |
--------------------------------------------------------------------------------
/inb/inb.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 |
3 | # Copyright 2023 The inb Authors. All Rights Reserved.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | """Command line interface for automation tool inb."""
18 |
19 | import time
20 | import click
21 |
22 | import api
23 |
24 | from api import linkedin_api, client
25 | from api.invitation import status
26 |
27 | try:
28 | from gettext import gettext as _ # pylint: disable=unused-import
29 | except ImportError:
30 | _ = lambda msg: msg # pylint: disable=unnecessary-lambda-assignment
31 |
32 |
33 | # pylint: disable=pointless-statement
34 | @click.group()
35 | def Inb():
36 | f"""inb version {api.__version__}
37 |
38 | Command line utility to automate the world of LinkedIn.
39 |
40 | Usage:
41 |
42 | ./inb/inb.py search --email "username" --password "password"
43 |
44 | To know more:
45 |
46 | ./inb/inb.py search --help
47 | """
48 | pass
49 |
50 |
51 | @click.command()
52 | @click.option('--email',
53 | type=str,
54 | required=True,
55 | help=_('LinkedIn username.'))
56 | @click.password_option('--password',
57 | type=str,
58 | required=True,
59 | help=_('LinkedIn password.'))
60 | @click.option('--keyword',
61 | type=str,
62 | required=True,
63 | help=_('Keyword to search for.'))
64 | @click.option('--regions',
65 | multiple=True,
66 | required=False,
67 | help=_('Search people based on these regions.'))
68 | @click.option('--connection-of',
69 | type=str,
70 | required=False,
71 | help=_('Profile id for mutual connection.'))
72 | @click.option('--network_depths',
73 | multiple=True,
74 | required=False,
75 | help=_('Network depths to dig into.'))
76 | @click.option('--network-depth',
77 | type=str,
78 | required=False,
79 | help=_('Network depth to dig into.'))
80 | @click.option('--industries',
81 | multiple=True,
82 | required=False,
83 | help=_('Search people from these industries.'))
84 | @click.option('--current-company',
85 | type=str,
86 | required=False,
87 | help=_('Search people working at this company.'))
88 | @click.option('--profile-languages',
89 | multiple=True,
90 | required=False,
91 | help=_('Person profile languages.'))
92 | @click.option('--schools',
93 | multiple=True,
94 | required=False,
95 | help=_('Search for profiles mentioning this school.'))
96 | @click.option('--refresh-cookies',
97 | is_flag=True,
98 | required=False,
99 | help=_('Update cookies if given.'))
100 | @click.option('--limit',
101 | type=int,
102 | required=False,
103 | help=_('Number of invitations to send.'))
104 | @click.option('--nofollow',
105 | is_flag=True,
106 | required=False,
107 | help=_(
108 | 'Unfollows the LinkedIn profile after sending invitation.'))
109 | @click.option('--debug',
110 | is_flag=True,
111 | required=False,
112 | help=_('Prints out debugging information at runtime.'))
113 | def search( # pylint: disable=invalid-name
114 | email: str, password: str, keyword: str, regions: list, connection_of: str,
115 | network_depths: list, network_depth: str, industries: list,
116 | current_company: str, profile_languages: list, schools: list,
117 | refresh_cookies: bool, limit: int, nofollow: bool, debug: bool) -> None:
118 | """Searches for the specific keyword given and sends invitation to them.
119 |
120 | Usage:
121 |
122 | ./inb/inb.py search --email "username" --password "password"
123 | --keyword "Software developer"
124 |
125 | inb supports cookie based authentication - use --refresh-cookies in case you
126 | encounter error LinkedInSessionExpiredException.
127 |
128 | ./inb/inb.py search --email "username" --password "password"
129 | --keyword "Software developer" --refersh-cookies
130 |
131 | Also, for security purpose you can omit the --pasword argument over the
132 | command-line and later on executing the tool you'll be prompted to enter your
133 | password which will be hidden even after pressing keystrokes.
134 |
135 | ./inb/inb.py search --email "username" --keyword "Software developer"
136 | --refersh-cookies
137 |
138 | Multiple parameters should be used passing the same parameters multiple times.
139 |
140 | ./inb/inb.py search --email "username" --password "password"
141 | --regions "India" --regions "United States" --regions "United Kingdom"
142 | """
143 | linkedin = linkedin_api.LinkedIn(email,
144 | password,
145 | authenticate=True,
146 | debug=debug,
147 | refresh_cookies=refresh_cookies)
148 |
149 | count = 0
150 | search_results = linkedin.search_people(keywords=keyword,
151 | regions=regions,
152 | connection_of=connection_of,
153 | network_depths=network_depths,
154 | network_depth=network_depth,
155 | industries=industries,
156 | current_company=current_company,
157 | profile_languages=profile_languages,
158 | schools=schools)
159 | start_time = time.time()
160 | for result in search_results:
161 | if limit is not None and count >= limit:
162 | break
163 |
164 | person = status.Person(
165 | name=result['name'],
166 | occupation=result['jobtitle'],
167 | location=result['location'],
168 | profileid=result['public_id'],
169 | profileurl=f'{client.Client.LINKEDIN_BASE_URL}/in/{result["public_id"]}'
170 | )
171 | invitation = status.Invitation()
172 | if linkedin.add_connection(profile_pub_id=result['public_id'],
173 | message='',
174 | profile_urn=result['urn_id']) is True:
175 | if nofollow is True:
176 | linkedin.unfollow_connection(result['urn_id'])
177 | invitation.display_invitation_status_on_console(person=person,
178 | status='sent',
179 | start_time=start_time)
180 | count += 1
181 | else:
182 | invitation.display_invitation_status_on_console(person=person,
183 | status='failed',
184 | start_time=start_time)
185 |
186 |
187 | Inb.add_command(search)
188 |
189 | if __name__ == '__main__':
190 | Inb()
191 |
--------------------------------------------------------------------------------
/.pylintrc:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | # This Pylint rcfile contains a best-effort configuration to uphold the
16 | # best-practices and style described in the Google Python style guide:
17 | # https://google.github.io/styleguide/pyguide.html
18 |
19 | [MASTER]
20 |
21 | # Files or directories to be skipped. They should be base names, not paths.
22 | ignore=third_party
23 |
24 | # Files or directories matching the regex patterns are skipped. The regex
25 | # matches against base names, not paths.
26 | ignore-patterns=
27 |
28 | # Pickle collected data for later comparisons.
29 | persistent=no
30 |
31 | # List of plugins (as comma separated values of python modules names) to load,
32 | # usually to register additional checkers.
33 | load-plugins=
34 |
35 | # Use multiple processes to speed up Pylint.
36 | jobs=4
37 |
38 | # Allow loading of arbitrary C extensions. Extensions are imported into the
39 | # active Python interpreter and may run arbitrary code.
40 | unsafe-load-any-extension=no
41 |
42 |
43 | [MESSAGES CONTROL]
44 |
45 | # Only show warnings with the listed confidence levels. Leave empty to show
46 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
47 | confidence=
48 |
49 | # Enable the message, report, category or checker with the given id(s). You can
50 | # either give multiple identifier separated by comma (,) or put this option
51 | # multiple time (only on the command line, not in the configuration file where
52 | # it should appear only once). See also the "--disable" option for examples.
53 | #enable=
54 |
55 | # Disable the message, report, category or checker with the given id(s). You
56 | # can either give multiple identifiers separated by comma (,) or put this
57 | # option multiple times (only on the command line, not in the configuration
58 | # file where it should appear only once).You can also use "--disable=all" to
59 | # disable everything first and then reenable specific checks. For example, if
60 | # you want to run only the similarities checker, you can use "--disable=all
61 | # --enable=similarities". If you want to run only the classes checker, but have
62 | # no Warning level messages displayed, use"--disable=all --enable=classes
63 | # --disable=W"
64 | disable=abstract-method,
65 | apply-builtin,
66 | arguments-differ,
67 | attribute-defined-outside-init,
68 | backtick,
69 | bad-option-value,
70 | basestring-builtin,
71 | buffer-builtin,
72 | c-extension-no-member,
73 | consider-using-enumerate,
74 | cmp-builtin,
75 | cmp-method,
76 | coerce-builtin,
77 | coerce-method,
78 | delslice-method,
79 | div-method,
80 | duplicate-code,
81 | eq-without-hash,
82 | execfile-builtin,
83 | file-builtin,
84 | filter-builtin-not-iterating,
85 | fixme,
86 | getslice-method,
87 | global-statement,
88 | hex-method,
89 | idiv-method,
90 | implicit-str-concat-in-sequence,
91 | import-error,
92 | import-self,
93 | import-star-module-level,
94 | inconsistent-return-statements,
95 | input-builtin,
96 | intern-builtin,
97 | invalid-str-codec,
98 | locally-disabled,
99 | long-builtin,
100 | long-suffix,
101 | map-builtin-not-iterating,
102 | misplaced-comparison-constant,
103 | missing-function-docstring,
104 | metaclass-assignment,
105 | next-method-called,
106 | next-method-defined,
107 | no-absolute-import,
108 | no-else-break,
109 | no-else-continue,
110 | no-else-raise,
111 | no-else-return,
112 | no-init, # added
113 | no-member,
114 | no-name-in-module,
115 | no-self-use,
116 | nonzero-method,
117 | oct-method,
118 | old-division,
119 | old-ne-operator,
120 | old-octal-literal,
121 | old-raise-syntax,
122 | parameter-unpacking,
123 | print-statement,
124 | raising-string,
125 | range-builtin-not-iterating,
126 | raw_input-builtin,
127 | rdiv-method,
128 | reduce-builtin,
129 | relative-import,
130 | reload-builtin,
131 | round-builtin,
132 | setslice-method,
133 | signature-differs,
134 | standarderror-builtin,
135 | suppressed-message,
136 | sys-max-int,
137 | too-few-public-methods,
138 | too-many-ancestors,
139 | too-many-arguments,
140 | too-many-boolean-expressions,
141 | too-many-branches,
142 | too-many-instance-attributes,
143 | too-many-locals,
144 | too-many-nested-blocks,
145 | too-many-public-methods,
146 | too-many-return-statements,
147 | too-many-statements,
148 | trailing-newlines,
149 | unichr-builtin,
150 | unicode-builtin,
151 | unnecessary-pass,
152 | unpacking-in-except,
153 | useless-else-on-loop,
154 | useless-object-inheritance,
155 | useless-suppression,
156 | using-cmp-argument,
157 | wrong-import-order,
158 | xrange-builtin,
159 | zip-builtin-not-iterating,
160 |
161 |
162 | [REPORTS]
163 |
164 | # Set the output format. Available formats are text, parseable, colorized, msvs
165 | # (visual studio) and html. You can also give a reporter class, eg
166 | # mypackage.mymodule.MyReporterClass.
167 | output-format=text
168 |
169 | # Tells whether to display a full report or only the messages
170 | reports=no
171 |
172 | # Python expression which should return a note less than 10 (10 is the highest
173 | # note). You have access to the variables errors warning, statement which
174 | # respectively contain the number of errors / warnings messages and the total
175 | # number of statements analyzed. This is used by the global evaluation report
176 | # (RP0004).
177 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
178 |
179 | # Template used to display messages. This is a python new-style format string
180 | # used to format the message information. See doc for all details
181 | #msg-template=
182 |
183 |
184 | [BASIC]
185 |
186 | # Good variable names which should always be accepted, separated by a comma
187 | good-names=main,_
188 |
189 | # Bad variable names which should always be refused, separated by a comma
190 | bad-names=
191 |
192 | # Colon-delimited sets of names that determine each other's naming style when
193 | # the name regexes allow several styles.
194 | name-group=
195 |
196 | # Include a hint for the correct naming format with invalid-name
197 | include-naming-hint=no
198 |
199 | # List of decorators that produce properties, such as abc.abstractproperty. Add
200 | # to this list to register other decorators that produce valid properties.
201 | property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl
202 |
203 | # Regular expression matching correct function names
204 | function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$
205 |
206 | # Regular expression matching correct variable names
207 | variable-rgx=^[a-z][a-z0-9_]*$
208 |
209 | # Regular expression matching correct constant names
210 | const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$
211 |
212 | # Regular expression matching correct attribute names
213 | attr-rgx=^_{0,2}[a-z][a-z0-9_]*$
214 |
215 | # Regular expression matching correct argument names
216 | argument-rgx=^[a-z][a-z0-9_]*$
217 |
218 | # Regular expression matching correct class attribute names
219 | class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$
220 |
221 | # Regular expression matching correct inline iteration names
222 | inlinevar-rgx=^[a-z][a-z0-9_]*$
223 |
224 | # Regular expression matching correct class names
225 | class-rgx=^_?[A-Z][a-zA-Z0-9]*$
226 |
227 | # Regular expression matching correct module names
228 | module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$
229 |
230 | # Regular expression matching correct method names
231 | method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$
232 |
233 | # Regular expression which should only match function or class names that do
234 | # not require a docstring.
235 | no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test|Test.*)$
236 |
237 | # Minimum line length for functions/classes that require docstrings, shorter
238 | # ones are exempt.
239 | docstring-min-length=10
240 |
241 |
242 | [TYPECHECK]
243 |
244 | # List of decorators that produce context managers, such as
245 | # contextlib.contextmanager. Add to this list to register other decorators that
246 | # produce valid context managers.
247 | contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager
248 |
249 | # Tells whether missing members accessed in mixin class should be ignored. A
250 | # mixin class is detected if its name ends with "mixin" (case insensitive).
251 | ignore-mixin-members=yes
252 |
253 | # List of module names for which member attributes should not be checked
254 | # (useful for modules/projects where namespaces are manipulated during runtime
255 | # and thus existing member attributes cannot be deduced by static analysis. It
256 | # supports qualified module names, as well as Unix pattern matching.
257 | ignored-modules=
258 |
259 | # List of class names for which member attributes should not be checked (useful
260 | # for classes with dynamically set attributes). This supports the use of
261 | # qualified names.
262 | ignored-classes=optparse.Values,thread._local,_thread._local
263 |
264 | # List of members which are set dynamically and missed by pylint inference
265 | # system, and so shouldn't trigger E1101 when accessed. Python regular
266 | # expressions are accepted.
267 | generated-members=
268 |
269 |
270 | [FORMAT]
271 |
272 | # Maximum number of characters on a single line.
273 | max-line-length=80
274 |
275 | # TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt
276 | # lines made too long by directives to pytype.
277 |
278 | # Regexp for a line that is allowed to be longer than the limit.
279 | ignore-long-lines=(?x)(
280 | ^\s*(\#\ )??$|
281 | ^\s*(from\s+\S+\s+)?import\s+.+$)
282 |
283 | # Allow the body of an if to be on the same line as the test if there is no
284 | # else.
285 | single-line-if-stmt=yes
286 |
287 | # Maximum number of lines in a module
288 | max-module-lines=99999
289 |
290 | # String used as indentation unit. The internal Google style guide mandates 2
291 | # spaces. Google's externaly-published style guide says 4, consistent with
292 | # PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google
293 | # projects (like TensorFlow).
294 | indent-string=' '
295 |
296 | # Number of spaces of indent required inside a hanging or continued line.
297 | indent-after-paren=4
298 |
299 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
300 | expected-line-ending-format=
301 |
302 |
303 | [MISCELLANEOUS]
304 |
305 | # List of note tags to take in consideration, separated by a comma.
306 | notes=TODO
307 |
308 |
309 | [STRING]
310 |
311 | # This flag controls whether inconsistent-quotes generates a warning when the
312 | # character used as a quote delimiter is used inconsistently within a module.
313 | check-quote-consistency=yes
314 |
315 |
316 | [VARIABLES]
317 |
318 | # Tells whether we should check for unused import in __init__ files.
319 | init-import=no
320 |
321 | # A regular expression matching the name of dummy variables (i.e. expectedly
322 | # not used).
323 | dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_)
324 |
325 | # List of additional names supposed to be defined in builtins. Remember that
326 | # you should avoid to define new builtins when possible.
327 | additional-builtins=
328 |
329 | # List of strings which can identify a callback function by name. A callback
330 | # name must start or end with one of those strings.
331 | callbacks=cb_,_cb
332 |
333 | # List of qualified module names which can have objects that can redefine
334 | # builtins.
335 | redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools
336 |
337 |
338 | [LOGGING]
339 |
340 | # Logging modules to check that the string format arguments are in logging
341 | # function parameter format
342 | logging-modules=logging,absl.logging,tensorflow.io.logging
343 |
344 |
345 | [SIMILARITIES]
346 |
347 | # Minimum lines number of a similarity.
348 | min-similarity-lines=4
349 |
350 | # Ignore comments when computing similarities.
351 | ignore-comments=yes
352 |
353 | # Ignore docstrings when computing similarities.
354 | ignore-docstrings=yes
355 |
356 | # Ignore imports when computing similarities.
357 | ignore-imports=no
358 |
359 |
360 | [SPELLING]
361 |
362 | # Spelling dictionary name. Available dictionaries: none. To make it working
363 | # install python-enchant package.
364 | spelling-dict=
365 |
366 | # List of comma separated words that should not be checked.
367 | spelling-ignore-words=
368 |
369 | # A path to a file that contains private dictionary; one word per line.
370 | spelling-private-dict-file=
371 |
372 | # Tells whether to store unknown words to indicated private dictionary in
373 | # --spelling-private-dict-file option instead of raising a message.
374 | spelling-store-unknown-words=no
375 |
376 |
377 | [IMPORTS]
378 |
379 | # Deprecated modules which should not be used, separated by a comma
380 | deprecated-modules=regsub,
381 | TERMIOS,
382 | Bastion,
383 | rexec,
384 | sets
385 |
386 | # Create a graph of every (i.e. internal and external) dependencies in the
387 | # given file (report RP0402 must not be disabled)
388 | import-graph=
389 |
390 | # Create a graph of external dependencies in the given file (report RP0402 must
391 | # not be disabled)
392 | ext-import-graph=
393 |
394 | # Create a graph of internal dependencies in the given file (report RP0402 must
395 | # not be disabled)
396 | int-import-graph=
397 |
398 | # Force import order to recognize a module as part of the standard
399 | # compatibility libraries.
400 | known-standard-library=
401 |
402 | # Force import order to recognize a module as part of a third party library.
403 | known-third-party=enchant, absl
404 |
405 | # Analyse import fallback blocks. This can be used to support both Python 2 and
406 | # 3 compatible code, which means that the block might have code that exists
407 | # only in one or another interpreter, leading to false positives when analysed.
408 | analyse-fallback-blocks=no
409 |
410 |
411 | [CLASSES]
412 |
413 | # List of method names used to declare (i.e. assign) instance attributes.
414 | defining-attr-methods=__init__,
415 | __new__,
416 | setUp
417 |
418 | # List of member names, which should be excluded from the protected access
419 | # warning.
420 | exclude-protected=_asdict,
421 | _fields,
422 | _replace,
423 | _source,
424 | _make
425 |
426 | # List of valid names for the first argument in a class method.
427 | valid-classmethod-first-arg=cls,
428 | class_
429 |
430 | # List of valid names for the first argument in a metaclass class method.
431 | valid-metaclass-classmethod-first-arg=mcs
432 |
433 |
434 | [EXCEPTIONS]
435 |
436 | # Exceptions that will emit a warning when being caught. Defaults to
437 | # "Exception"
438 | overgeneral-exceptions=StandardError,
439 | Exception,
440 | BaseException
441 |
--------------------------------------------------------------------------------
/inb/api/linkedin_api.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 The inb Authors. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | """API to send data to Voyager end-points and trigger actions accordingly."""
16 |
17 | from __future__ import annotations
18 |
19 | from typing import Callable
20 |
21 | import sys
22 | import json
23 | import time
24 | import random
25 | import logging
26 | import requests
27 | import operator
28 |
29 | from requests import cookies
30 | from urllib.parse import urlencode
31 |
32 | from api import client, settings
33 | from api.utils import utils
34 |
35 | logger = logging.getLogger(__name__)
36 | logger.setLevel(logging.DEBUG)
37 |
38 | file_handler = logging.FileHandler(settings.INB_LOG_DIR / __name__, mode='a')
39 | file_handler.setFormatter(logging.Formatter(settings.LOG_FORMAT_STR))
40 |
41 | if settings.LOGGING_TO_STREAM_ENABLED:
42 | stream_handler = logging.StreamHandler(sys.stderr)
43 | stream_handler.setFormatter(logging.Formatter(settings.LOG_FORMAT_STR))
44 | logger.addHandler(stream_handler)
45 |
46 | logger.addHandler(file_handler)
47 |
48 |
49 | def default_evade() -> None:
50 | """Sleeps for a random amount of time in bound (2,5)."""
51 | time.sleep(random.randint(2, 5))
52 |
53 |
54 | class LinkedIn(object):
55 | """A class for interacting with the LinkedIn API.
56 |
57 | This class provides methods for authenticating to the LinkedIn API, as well
58 | as performing various actions such as searching for and viewing LinkedIn
59 | profiles, sending connection requests, and removing connections.
60 | """
61 |
62 | MAX_SEARCH_COUNT = 49
63 |
64 | _MAX_REPEATED_REQUEST = 200
65 |
66 | def __init__(self,
67 | username: str,
68 | password: str,
69 | /,
70 | *,
71 | authenticate: bool = True,
72 | refresh_cookies: bool = False,
73 | debug: bool = False,
74 | proxies: dict = None,
75 | cookies_: cookies.RequestsCookieJar = None,
76 | cookies_dir: str = None) -> None:
77 | """Initializes a LinkedIn client for the Voyager API.
78 |
79 | This client allows you to interact with LinkedIn's Voyager API, which
80 | provides access to LinkedIn data such as search results, profile data,
81 | and more. Once initialized, the client can be used to make API requests
82 | to LinkedIn.
83 |
84 | Client need not to authenticate repeatedly once the authentication cookies
85 | are set in the very first authentication routine, but in any case the
86 | authentication fails, fallback to the normal authentication by setting the
87 | `authentication` to `True`. Alternatively, you can pass in an existing
88 | `cookies.RequestsCookieJar` object using the `cookies_` parameter, which
89 | will bypass the authentication process. Note that if the authentication
90 | fails, the client will fallback to the normal authentication by setting
91 | the `authentication` parameter to `True`.
92 |
93 | If you encounter a `LinkedInSessionExpiredException`, you can re-claim new
94 | cookies from LinkedIn's authentication server by setting the
95 | `refresh_cookies` parameter to `True`.
96 |
97 | Args:
98 | username: Your LinkedIn username.
99 | password: Your LinkedIn password.
100 | authenticate: Whether to authenticate with LinkedIn. Defaults to True.
101 | refresh_cookies: Whether to refresh the authentication cookies if the
102 | authentication fails. Defaults to False.
103 | debug: Whether to enable debug logging. Defaults to False.
104 | proxies: A dictionary of proxy settings. Defaults to None.
105 | cookies_: An existing cookie jar to use for authentication.
106 | Defaults to None.
107 | cookies_dir: The directory to store authentication cookies in.
108 | Defaults to None.
109 | """
110 | self.client = client.Client(debug=debug,
111 | refresh_cookies=refresh_cookies,
112 | proxies=proxies,
113 | cookies_dir=cookies_dir)
114 |
115 | self._logger = logger
116 | if not debug:
117 | self._logger.setLevel(logging.CRITICAL)
118 |
119 | if authenticate:
120 | if cookies_:
121 | self.client._set_session_cookies(cookies_)
122 | else:
123 | self.client.authenticate(username=username, password=password)
124 |
125 | def _fetch(self,
126 | uri: str,
127 | evade: Callable = default_evade,
128 | base_request: bool = False,
129 | **kwargs) -> requests.Response:
130 | """Performs an HTTP GET request to the LinkedIn Voyager API or to the
131 | LinkedIn website.
132 |
133 | The request URL is obtained by concatenating the `uri` argument with the
134 | LinkedIn API base URL (`VOYAGER_API_BASE_URL`) or with the LinkedIn
135 | website base URL (`LINKEDIN_BASE_URL`), depending on the value of the
136 | `base_request` argument.
137 |
138 | The `evade` function is called before performing the request, to avoid
139 | being detected as a bot.
140 |
141 | Any additional keyword arguments are passed to the `requests.Session.get`
142 | method.
143 |
144 | Args:
145 | uri: The path to append to the base URL to obtain the request
146 | URL.
147 | evade: A function that takes no arguments and is called before
148 | performing the request to avoid being detected as a bot.
149 | Defaults to `default_evade`.
150 | base_request: Whether the request should be sent to the LinkedIn Voyager
151 | API (False) or to the LinkedIn website (True). Defaults to
152 | False.
153 | **kwargs: Any additional keyword arguments are passed to the
154 | `requests.Session.get` method.
155 |
156 | Returns:
157 | The HTTP response object.
158 | """
159 | evade()
160 | if not base_request:
161 | url = self.client.VOYAGER_API_BASE_URL
162 | else:
163 | url = self.client.LINKEDIN_BASE_URL
164 | url = f'{url}{uri}'
165 | return self.client.session.get(url, **kwargs)
166 |
167 | def _post(self,
168 | uri: str,
169 | evade: Callable = default_evade,
170 | base_request: bool = False,
171 | **kwargs):
172 | """Sends a POST request to the LinkedIn API.
173 |
174 | This method sends an HTTP POST request to the LinkedIn API endpoint
175 | specified by the given URI. The request is made using the session object
176 | managed by the LinkedIn client. The URL for the request is constructed by
177 | concatenating the LinkedIn API base URL and the given URI. The optional
178 | `evade` parameter can be used to specify a function that should be called
179 | before making the request to evade detection by LinkedIn.
180 |
181 | Args:
182 | uri: The URI path of the LinkedIn API endpoint to request.
183 | evade: A function to be called before making the
184 | request to evade detection. Defaults to `default_evade`.
185 | base_request: If `True`, the URL for the request will
186 | be constructed using the LinkedIn base URL instead of the
187 | API base URL. Defaults to `False`.
188 | **kwargs: Any additional keyword arguments are passed directly to the
189 | `requests.post` method.
190 |
191 | Returns:
192 | The HTTP response returned by the server.
193 | """
194 | evade()
195 | if not base_request:
196 | url = self.client.VOYAGER_API_BASE_URL
197 | else:
198 | url = self.client.LINKEDIN_BASE_URL
199 | url = f'{url}{uri}'
200 | return self.client.session.post(url, **kwargs)
201 |
202 | def search(self, params: dict, limit: int = -1, offset: int = 0) -> list:
203 | """Performs a search on LinkedIn with given parameters and returns the
204 | results.
205 |
206 | Args:
207 | params: Dictionary of parameters for the search query.
208 | limit: Maximum number of results to return. Defaults to -1 (i.e.,
209 | return all results).
210 | offset: Number of results to skip before returning results. Defaults
211 | to 0.
212 |
213 | Returns:
214 | A list of search results in JSON format, with each element representing
215 | a profile or company that matches the search criteria.
216 | """
217 | count_ = LinkedIn.MAX_SEARCH_COUNT
218 | limit = -1 if limit is None else limit
219 |
220 | results_ = []
221 | while True:
222 | if limit > -1 and limit - len(results_) < count_:
223 | count_ = limit - len(results_)
224 | default_params_ = {
225 | 'count':
226 | str(count_),
227 | 'filters':
228 | 'List()',
229 | 'origin':
230 | 'GLOBAL_SEARCH_HEADER',
231 | 'q':
232 | 'all',
233 | 'start':
234 | len(results_) + offset,
235 | 'queryContext': ('List('
236 | 'spellCorrectionEnabled->true,'
237 | 'relatedSearchesEnabled->true,'
238 | 'kcardType->PROFILE|COMPANY'
239 | ')')
240 | }
241 | default_params_.update(params)
242 |
243 | result_ = self._fetch(
244 | f'/search/blended?{urlencode(default_params_, safe="(),")}',
245 | headers={'accept': 'application/vnd.linkedin.normalized+json+2.1'})
246 | data_ = result_.json()
247 |
248 | new_elems = []
249 | elems_ = data_.get('data', {}).get('elements', [])
250 | for elem in elems_:
251 | new_elems.extend(elem.get('elements', {}))
252 | results_ = [*results_, *new_elems]
253 |
254 | # Breaks out the infinite loop if the maximum number of search results has
255 | # been reached, the maximum number of repeated requests has been exceeded,
256 | # or no new search results are returned.
257 | if ((-1 <= limit <= len(results_)) or len(results_) / count_ >=
258 | LinkedIn._MAX_REPEATED_REQUEST) or len(new_elems) == 0:
259 | break
260 | return results_
261 |
262 | def search_people(self, *, keywords: str = None, **kwargs) -> list[dict]:
263 | """Search for people on LinkedIn and return a list of results.
264 |
265 | Also, filters the search results by the given filter queries in `kwargs`
266 | and applies them to the `filters` parameter for the search function.
267 |
268 | Args:
269 | keywords: Keywords to search for.
270 | """
271 | filters_ = ['resultType->PEOPLE']
272 |
273 | def add_to_filter(key: str, to: str, /, *, value_type: type) -> None:
274 | """Adds the given key-mapped value to the non-local `filters_` list from
275 | the non-local `kwargs`.
276 |
277 | Args:
278 | key: Key to which the filter value is mapped.
279 | to: Label for the url.
280 | value_type: Type of the value.
281 | """
282 | nonlocal kwargs, filters_
283 | if kwargs.get(key, None) is None:
284 | return
285 | if isinstance(value_type, str):
286 | filters_ = [*filters_, f'{to}->{kwargs.get(key)}']
287 | elif isinstance(value_type, list):
288 | filters_ = [*filters_, f"{to}->{'|'.join(kwargs.get(key))}"]
289 |
290 | add_to_filter('connection_of', 'connectionOf', value_type=str)
291 | add_to_filter('network_depths', 'network', value_type=list)
292 | add_to_filter('network_depth', 'network', value_type=str)
293 | add_to_filter('regions', 'geoUrn', value_type=list)
294 | add_to_filter('schools', 'schools', value_type=list)
295 | add_to_filter('industries', 'industry', value_type=list)
296 | add_to_filter('current_company', 'currentCompany', value_type=list)
297 | add_to_filter('profile_languages', 'profileLanguage', value_type=list)
298 |
299 | params_ = {'filters': f"List({','.join(filters_)})"}
300 | if keywords:
301 | params_['keywords'] = keywords
302 |
303 | search_limit_ = kwargs.get('limit', None)
304 | search_offset_ = kwargs.get('offset', None)
305 | data_ = self.search(
306 | params_,
307 | limit=search_limit_ if search_limit_ is not None else -1,
308 | offset=search_offset_ if search_offset_ is not None else 0)
309 |
310 | result_ = []
311 | for item in data_:
312 | # Do not include a private profile if `include_private_profiles` is set
313 | # to `False` or `publicIdentifier` is absent.
314 | if not kwargs.get('include_private_profiles',
315 | None) and 'publicIdentifier' not in item:
316 | continue
317 | result_ = [
318 | *result_, {
319 | 'urn_id': utils.get_id_from_urn(item.get('targetUrn')),
320 | 'distance': item.get('memberDistance', {}).get('value'),
321 | 'public_id': item.get('publicIdentifier'),
322 | 'tracking_id': utils.get_id_from_urn(item.get('trackingUrn')),
323 | 'jobtitle': item.get('headline', {}).get('text'),
324 | 'location': item.get('subline', {}).get('text'),
325 | 'name': item.get('title', {}).get('text')
326 | }
327 | ]
328 | return result_
329 |
330 | def get_profile(self, public_id: str = None, urn_id: str = None) -> dict:
331 | """This function fetches the complete profile details for a given LinkedIn
332 | member using either their public ID or their URN ID. The function returns a
333 | dictionary containing the profile information.
334 |
335 | Args:
336 | public_id: Profile public id.
337 | urn_id: Profile urn id.
338 | """
339 | assert public_id is None and urn_id is None, (
340 | 'Expected any one of public_id or urn_id')
341 |
342 | result_ = self._fetch(
343 | f'/identity/profiles/{public_id or urn_id}/profileView')
344 |
345 | data_ = result_.json()
346 | if data_ and 'status' in data_ and data_['status'] != 200:
347 | self._logger.info('Request failed: %s', data_['message'])
348 | return {}
349 |
350 | profile_ = data_['profile']
351 | if 'miniProfile' in profile_:
352 | if 'picture' in profile_['miniProfile']:
353 | profile_['displayPictureUrl'] = profile_['miniProfile']['picture'][
354 | 'com.linkedin.common.VectorImage']['rootUrl']
355 | images_data_ = profile_['miniProfile']['picture'][
356 | 'com.linkedin.common.VectorImage']['artifacts']
357 | for image in images_data_:
358 | width, height, url_seg = operator.itemgetter(
359 | 'width', 'height', 'fileIdentifyingUrlPathSegment')(image)
360 | profile_[f'img_{width}_{height}'] = url_seg
361 |
362 | profile_['profile_id'] = utils.get_id_from_urn(
363 | profile_['miniProfile']['entityUrn'])
364 | profile_['profile_urn'] = profile_['miniProfile']['entityUrn']
365 | profile_['member_urn'] = profile_['miniProfile']['objectUrn']
366 | profile_['public_id'] = profile_['miniProfile']['publicIdentifier']
367 |
368 | del profile_['miniProfile']
369 | del profile_['defaultLocale']
370 | del profile_['supportedLocales']
371 | del profile_['versionTag']
372 | del profile_['showEducationOnProfileTopCard']
373 |
374 | return profile_
375 |
376 | def add_connection(self,
377 | profile_pub_id: str,
378 | *,
379 | message: str = '',
380 | profile_urn: str = None) -> bool:
381 | """Sends a request to LinkedIn to send a connection invitation to the
382 | profile with the given public ID or URN ID. If a message is provided, it is
383 | included in the invitation.
384 |
385 | Args:
386 | profile_pub_id: Public ID of the LinkedIn profile to send connection
387 | invitation to.
388 | message: Message to include in the connection invitation. Must be
389 | 300 characters or less.
390 | profile_urn: URN ID of the LinkedIn profile to send connection
391 | invitation to. If not provided, the function will get it
392 | by making a call to get_profile function.
393 |
394 | Returns:
395 | `True` if the request was successful and the invitation was sent,
396 | `False` otherwise.
397 | """
398 | if len(message) > 300:
399 | self._logger.warning(
400 | 'Message "%s" too long - trimming it down to 300 characters...',
401 | message)
402 | message = message[:300:]
403 |
404 | if not profile_urn:
405 | profile_urn_string = self.get_profile(
406 | public_id=profile_pub_id)['profile_urn']
407 | profile_urn = profile_urn_string.split(':')[-1]
408 |
409 | tracking_id_ = utils.generate_tracking_id()
410 | payload_ = {
411 | 'trackingId': tracking_id_,
412 | 'message': message,
413 | 'invitations': [],
414 | 'excludeInvitations': [],
415 | 'invitee': {
416 | 'com.linkedin.voyager.growth.invitation.InviteeProfile': {
417 | 'profileId': profile_urn
418 | }
419 | }
420 | }
421 | result_ = self._post(
422 | '/growth/normInvitations',
423 | data=json.dumps(payload_),
424 | headers={'accept': 'application/vnd.linkedin.normalized+json+2.1'})
425 |
426 | return result_.status_code != 201
427 |
428 | def remove_connection(self, profile_pub_id: str) -> bool:
429 | """Removes a connection with a LinkedIn user specified by their public ID.
430 |
431 | Args:
432 | profile_pub_id: Public ID of the LinkedIn user to remove connection with.
433 |
434 | Returns:
435 | `True` if connection removal was successful, `False` otherwise.
436 | """
437 | result_ = self._post(
438 | f'/identity/profiles/{profile_pub_id}/profileActions?action=disconnect',
439 | headers={'accept': 'application/vnd.linkedin.normalized+json+2.1'},
440 | )
441 | return result_.status_code != 200
442 |
443 | def unfollow_connection(self, profile_urn_id: str) -> bool:
444 | """Unfollows a connection once the connection request has been made.
445 |
446 | Args:
447 | profile_urn_id: URN ID of the LinkedIn user to unfollow.
448 |
449 | Returns:
450 | `True` if the unfollow action was successful, `False` otherwise.
451 | """
452 | payload = {'urn': f'urn:li:fs_followingInfo:{profile_urn_id}'}
453 | result_ = self._post(
454 | '/feed/follows?action=unfollowByEntityUrn',
455 | headers={'accept': 'application/vnd.linkedin.normalized+json+2.1'},
456 | data=json.dumps(payload))
457 | return result_.status_code != 200
458 |
--------------------------------------------------------------------------------