6 Commits
Author SHA1 Message Date
Tirsvad bd73485e35 Resolve pending commit links (MIL-004)
TirSystem/git-workflows: OpenGrep / opengrep (push) Skipped
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Skipped
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Skipped
TirSystem/git-workflows: Publish accepted milestones / publish (push) Skipped
TirSystem/git-workflows: Validate / validate (push) Skipped
TirSystem/git-workflows: Validate / validate (pull_request) Successful in 39s
TirSystem/git-workflows: OpenGrep / opengrep (pull_request) Successful in 4m43s
2026-11-04 12:36:11 +08:00
Tirsvad 0e1208726f Add wiki in English, Danish and Bahasa Malaysia (MIL-004)
Task: MIL-004#1
2026-11-04 12:36:10 +08:00
Tirsvad f4d8b41cdf Add lecture-step screenshots and README steps (MIL-003)
TirSystem/git-workflows: OpenGrep / opengrep (push) Skipped
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Skipped
TirSystem/git-workflows: Publish accepted milestones / publish (push) Skipped
TirSystem/git-workflows: Validate / validate (push) Skipped
TirSystem/git-workflows: Validate / validate (pull_request) Successful in 36s
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Skipped
TirSystem/git-workflows: OpenGrep / opengrep (pull_request) Successful in 5m13s
Task: MIL-003#1
2026-11-04 12:34:35 +08:00
Tirsvad e9083665f1 Correct URL and locators against the live mock (MIL-002)
TirSystem/git-workflows: OpenGrep / opengrep (push) Skipped
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Skipped
TirSystem/git-workflows: Validate / validate (push) Skipped
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Skipped
TirSystem/git-workflows: Publish accepted milestones / publish (push) Skipped
TirSystem/git-workflows: Validate / validate (pull_request) Successful in 33s
TirSystem/git-workflows: OpenGrep / opengrep (pull_request) Successful in 5m3s
Task: MIL-002#3
2026-11-04 12:33:54 +08:00
Tirsvad 1803a2a23f Resolve pending commit links (MIL-002)
TirSystem/git-workflows: OpenGrep / opengrep (push) Skipped
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Skipped
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Skipped
TirSystem/git-workflows: Publish accepted milestones / publish (push) Skipped
TirSystem/git-workflows: Validate / validate (push) Skipped
TirSystem/git-workflows: Validate / validate (pull_request) Successful in 32s
TirSystem/git-workflows: OpenGrep / opengrep (pull_request) Successful in 3m16s
2026-11-04 12:13:15 +08:00
Tirsvad 2a2838fba3 Add InstaFollower bot, tests and Python review (MIL-002)
Task: MIL-002#1
2026-11-04 12:13:13 +08:00
60 changed files with 1013 additions and 8 deletions
+42 -3
View File
@@ -17,12 +17,41 @@ A Python Selenium bot from Day 52 of Udemy's "100 Days of Code: The Complete Pyt
The assignment has five steps: get credentials, create the `InstaFollower` class, log in, find the followers of the target account and follow them. The code keeps the assignment's names (`InstaFollower`, `login`, `find_followers`, `follow`).
- `src/instagram_followers/` holds the bot and `constants.py`.
- `src/instagram_followers/` holds the bot in clean-architecture layers (`domain`, `application`, `infrastructure`, `presentation`) and `constants.py`.
- `tests/` holds the pytest tests, which use a fake driver and need no browser or network.
- `docs/` holds the project documents and the wiki (English, Danish and Bahasa Malaysia).
The bot targets the mock service Share-a-Naan. Running bots against the real Instagram can get an account banned and is not supported.
### Lecture steps
Each step of the assignment, with what the screen looks like on the Share-a-Naan mock. Screenshots were taken on 2026-11-04.
**Step 1 - Get your credentials.** The mock's profile page lists your login (your email is the username) and the target accounts to pick `SIMILAR_ACCOUNT` from. The password is read from the environment and never stored in the repository.
![Target accounts on the Share-a-Naan profile page][img-step1]
**Step 2 - Create a class.** `InstaFollower` creates the Selenium driver in `__init__` and has the three methods `login()`, `find_followers()` and `follow()`.
![Outline of the InstaFollower class][img-step2]
**Step 3 - Log in.** The login page, then two pop-ups that are dismissed with the "Not Now" button, found by its text with `contains()`.
![Share-a-Naan login page][img-step3-login]
![Save your login info pop-up][img-step3-save]
![Turn on notifications pop-up][img-step3-notifications]
**Step 4 - Find the followers.** Open `/u/<account>`, click the followers link and scroll the list inside the modal (the `followers-scroll` element), not the page behind it.
![Profile of the target account][img-step4-profile]
![Followers modal][img-step4-modal]
![Followers modal scrolled to the end][img-step4-scrolled]
**Step 5 - Follow all the followers.** Click every "Follow" button with a 1 second pause. If a click is intercepted by the "Unfollow?" pop-up (an account that is already followed), click "Cancel" and carry on.
![Unfollow pop-up with a Cancel button][img-step5-unfollow]
![Followers after the follow loop][img-step5-followed]
## 📋 Requirements
- Python 3.13 or newer.
@@ -57,10 +86,10 @@ export INSTA_PASSWORD="your_password"
## ▶️ Run
```bash
python -m instagram_followers.main
python -m instagram_followers.presentation.main
```
Run it sparingly and log out between test runs by hand.
Run it sparingly and log out between test runs by hand. The mock has a "Reset Share-a-Naan" button on its profile page that clears your follows for a clean re-run.
## 🧪 Tests
@@ -99,6 +128,16 @@ The documents a newcomer needs, in reading order.
---
[img-step1]: ./docs/images/step1-target-accounts.jpg
[img-step2]: ./docs/images/step2-class-outline.png
[img-step3-login]: ./docs/images/step3-login-page.jpg
[img-step3-save]: ./docs/images/step3-save-info-popup.jpg
[img-step3-notifications]: ./docs/images/step3-notifications-popup.jpg
[img-step4-profile]: ./docs/images/step4-target-profile.jpg
[img-step4-modal]: ./docs/images/step4-followers-modal.jpg
[img-step4-scrolled]: ./docs/images/step4-followers-scrolled.jpg
[img-step5-unfollow]: ./docs/images/step5-unfollow-popup.jpg
[img-step5-followed]: ./docs/images/step5-followed.jpg
[license]: ./LICENSE
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/wiki
+1 -1
View File
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 005 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 009 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,62 @@
# Review of MIL-002
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-009 |
| CrossReference | [MIL-002], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-11-04 | Proposed | Jens Tirsvad Nielsen | S01 (AI as stand-in) | Initial version | [2a2838f] |
---
## Artifact Under Review
- Instance reviewed: [MIL-002]
- Checklist used: [QC-PY-001]
- Scope: full review of `src/` and `tests/` at the head of branch mil-002-follower-bot (code review of the milestone deliverable)
- Language and domain: n/a
- Language reviewer: none (the stand-in for S01 reads English and the IT domain)
- Mode: autonomous, not independent (reviewer is AI as stand-in for S01)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Meets the criterion; see src/instagram_followers. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Meets the criterion; see src/instagram_followers. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves files unchanged and `ruff check` reports no finding; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict src tests` reports no issue in 26 files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; the only caught exception, `ElementClickInterceptedException`, is handled on purpose by clicking Cancel, as the assignment says. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | Meets the criterion; see src/instagram_followers. |
| 7 | Files, locks and connections are managed with context managers | Pass | `InstaFollower` is a context manager and quits the driver it created; tests cover this. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions carry Doxygen-style `##` comments (the Doxyfile reads them), as the Product Owner required. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print or logging; credentials are hidden from repr and never logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No design class diagram exists: [PP-001] models no use cases. The structure (Follower port, InstaFollower adapter, run_bot use case) is recorded here as the deviation. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 18 tests in tests/unit and tests/integration use a fake driver, no network, injected sleep. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` clean; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Dependencies are declared and pinned in pyproject.toml (selenium, pytest, ruff, mypy). Fixed after the first draft of this review. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Meets the criterion; see src/instagram_followers. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no selenium (grep empty); no import cycle. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Meets the criterion; see src/instagram_followers. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers domain, application, infrastructure, presentation in snake_case; tests/unit, tests/integration, tests/e2e (e2e needs a container browser and is not yet written). |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | Meets the criterion; see src/instagram_followers. |
## Overall Verdict
Go — Every Mandatory criterion passes. Optional criterion 13 was fixed (pinned versions). Re-checked after the locators and the account URL were corrected against the live mock on 2026-11-04 (18 tests, ruff and mypy --strict clean).
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None; criterion 13 was fixed before this record was finalised. | S01 | - |
---
[MIL-002]: ../../milestones/mil-002-follower-bot.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[2a2838f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/commit/2a2838fba36dd4d83c3bb1a4a12fc2d143c7d211
+59
View File
@@ -0,0 +1,59 @@
# Review of MIL-004
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-010 |
| CrossReference | [MIL-004], [QC-WIKI-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-11-04 | Proposed | Jens Tirsvad Nielsen | S01 (AI as stand-in) | Initial version | [0e12087] |
---
## Artifact Under Review
- Instance reviewed: [MIL-004]
- Checklist used: [QC-WIKI-001]
- Scope: full review of docs/wiki on branch mil-004-wiki
- Language and domain: n/a
- Language reviewer: none (the stand-in for S01 reads English and the IT domain)
- Mode: autonomous, not independent (reviewer is AI as stand-in for S01)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | The wiki has a `_Sidebar.md`, because the host lists subpages only through it | Pass | exists. |
| 2 | The sidebar starts with the wiki's title in bold, then one list of the pages in the primary language (English), `Home` first | Pass | Bold title, one English page list, Home first. |
| 3 | The page list holds a link to every English page of the wiki, and no link points to a page that does not exist | Pass | All six English pages are listed; a script checked that no link in the wiki points to a missing page. |
| 4 | A `**Related**` section follows the page list only when the wiki has related wikis (for example the checklists wiki for a framework wiki); each entry is a link to that wiki's Home. A wiki with nothing related has no such section | N-A | The wiki has no related wikis, so no Related section exists, as the criterion requires. |
| 5 | A `**Languages**` section is the last section of the sidebar. It has one line per language, the language written in that language, linking to that language's `Home` page | Pass | Languages section is last, one line per language in that language. |
| 6 | The sidebar does not repeat the page list per language | Pass | The page list appears once. |
| 7 | A sidebar produced by a generator carries a comment that says so and names the generator; the generator is changed, not the page | N-A | The sidebar is written by hand, not by a generator. |
| 8 | The wiki has a `Languages` line on each page (criterion 11), so the sidebar is not the only way to switch language | Pass | Every page has the language line. |
| 9 | Every wiki exists in English and, as a minimum, in the language the project's registry names as the PO language; further languages are added on top, never instead of English | Pass | English plus Danish and Bahasa Malaysia, as the Product Owner asked (the PO language is English). |
| 10 | Every English page has a page in each other language of the wiki: the page sets are identical | Pass | 6 pages in each of en, da and ms (18 pages). |
| 11 | Every page starts with the language line, with the current language in bold and a link to the same page in each other language | Pass | Each page starts with the line, the current language in bold. |
| 12 | A page in a language other than English is named `<Page>-<code>.md`, with a BCP 47 code; English has no suffix | Pass | Suffixes -da and -ms, English has none. |
| 13 | Code blocks, commands, file names and IDs stay in English in every language | Pass | Code, commands, file names and IDs are English in all languages. |
| 14 | The sidebar, every page and every link were checked on the published wiki after the change, not only in the repository | Pending | The wiki is published only after the pull request merges into main, so it cannot be checked on the host yet. The images also resolve only after the merge. |
| 15 | A change to the layout rules (sections, order, naming) is made in the generator or template and in this checklist's source rules together | N-A | No change to layout rules. |
## Overall Verdict
Go-with-conditions — All checkable Mandatory criteria pass. Criterion 14 is Pending until the wiki is published, so Go-with-conditions is the highest verdict.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| After the merge, open the published wiki and check the sidebar, every page, every link and every image on the host | S01 | 2026-11-04 |
---
[MIL-004]: ../../milestones/mil-004-wiki.md
[QC-WIKI-001]: ../../../framework/qc/artifacts/qc-wiki.md
[0e12087]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/commit/0e1208726f3ae1b083b198598311137e2fc99f58
+15
View File
@@ -0,0 +1,15 @@
[English](Home) · **Dansk** · [Bahasa Malaysia](Home-ms)
# Instagram Followers
En Selenium-bot fra dag 52 i Udemy-kurset "100 Days of Code: The Complete Python Pro Bootcamp". Den logger ind på mock-tjenesten Share-a-Naan, åbner en målkonto og følger hver af dens følgere.
Wikien følger forelæsningens fem trin. Hver side har skærmbillederne fra trinnet og det, vi lærte af det.
- [Trin 1 - Loginoplysninger](Step-1-Credentials-da)
- [Trin 2 - Klasse](Step-2-Class-da)
- [Trin 3 - Login](Step-3-Login-da)
- [Trin 4 - Find følgerne](Step-4-Find-Followers-da)
- [Trin 5 - Følg](Step-5-Follow-da)
Kør den kun mod mock-tjenesten. Den rigtige Instagram kan udelukke en konto, der opfører sig som en bot. Se repositoriets README for kommandoerne til at installere, køre og teste.
+15
View File
@@ -0,0 +1,15 @@
[English](Home) · [Dansk](Home-da) · **Bahasa Malaysia**
# Instagram Followers
Bot Selenium daripada hari ke-52 kursus Udemy "100 Days of Code: The Complete Python Pro Bootcamp". Ia log masuk ke perkhidmatan tiruan Share-a-Naan, membuka akaun sasaran dan mengikut setiap pengikutnya.
Wiki ini mengikut lima langkah kuliah. Setiap halaman mempunyai tangkapan skrin langkah itu dan apa yang kami peroleh daripadanya.
- [Langkah 1 - Maklumat log masuk](Step-1-Credentials-ms)
- [Langkah 2 - Kelas](Step-2-Class-ms)
- [Langkah 3 - Log masuk](Step-3-Login-ms)
- [Langkah 4 - Cari pengikut](Step-4-Find-Followers-ms)
- [Langkah 5 - Ikut](Step-5-Follow-ms)
Jalankan hanya pada perkhidmatan tiruan. Instagram sebenar boleh menyekat akaun yang bertindak seperti bot. Lihat README repositori untuk arahan memasang, menjalankan dan menguji.
+15
View File
@@ -0,0 +1,15 @@
**English** · [Dansk](Home-da) · [Bahasa Malaysia](Home-ms)
# Instagram Followers
A Selenium bot from Day 52 of the Udemy course "100 Days of Code: The Complete Python Pro Bootcamp". It logs in to the Share-a-Naan mock service, opens a target account and follows each of its followers.
The wiki follows the five steps of the lecture. Each page has the screenshots of the step and what we gained from it.
- [Step 1 - Credentials](Step-1-Credentials)
- [Step 2 - Class](Step-2-Class)
- [Step 3 - Login](Step-3-Login)
- [Step 4 - Find the followers](Step-4-Find-Followers)
- [Step 5 - Follow](Step-5-Follow)
Run it only against the mock service. The real Instagram can ban an account that behaves like a bot. See the README of the repository for the commands to install, run and test.
+13
View File
@@ -0,0 +1,13 @@
[English](Step-1-Credentials) · **Dansk** · [Bahasa Malaysia](Step-1-Credentials-ms)
# Trin 1 - Loginoplysninger
Opgaven starter med en konto. Vi bruger mock-tjenesten Share-a-Naan i stedet for den rigtige Instagram, så en ændret side eller en udelukkelse ikke kan gøre skade. Mock-tjenestens profilside viser login (e-mailen er brugernavnet) og de målkonti, man kan vælge `SIMILAR_ACCOUNT` blandt.
![Target accounts on the profile page](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step1-target-accounts.jpg)
## Hvad vi lærte
- Et sikkert sted at øve: en mock-tjeneste lader os få logikken rigtig, før en rigtig konto er i fare.
- Hemmeligheder holdes ude af koden: adgangskoden læses fra miljøvariablerne `INSTA_USERNAME` og `INSTA_PASSWORD`, og `.env` står i `.gitignore`.
- Konstanter ligger i én fil, `constants.py`: `SIMILAR_ACCOUNT`, `BASE_URL` og `LOGIN_URL`.
+13
View File
@@ -0,0 +1,13 @@
[English](Step-1-Credentials) · [Dansk](Step-1-Credentials-da) · **Bahasa Malaysia**
# Langkah 1 - Maklumat log masuk
Tugasan bermula dengan sebuah akaun. Kami menggunakan perkhidmatan tiruan Share-a-Naan dan bukannya Instagram sebenar, supaya halaman yang berubah atau sekatan tidak menjejaskan sesiapa. Halaman profil perkhidmatan tiruan itu menunjukkan log masuk (e-mel ialah nama pengguna) dan akaun sasaran untuk memilih `SIMILAR_ACCOUNT`.
![Target accounts on the profile page](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step1-target-accounts.jpg)
## Apa yang kami peroleh
- Tempat selamat untuk berlatih: perkhidmatan tiruan membolehkan kami membetulkan logik sebelum akaun sebenar berisiko.
- Rahsia dijauhkan daripada kod: kata laluan dibaca daripada pemboleh ubah persekitaran `INSTA_USERNAME` dan `INSTA_PASSWORD`, dan `.env` ada dalam `.gitignore`.
- Pemalar disimpan dalam satu fail, `constants.py`: `SIMILAR_ACCOUNT`, `BASE_URL` dan `LOGIN_URL`.
+13
View File
@@ -0,0 +1,13 @@
**English** · [Dansk](Step-1-Credentials-da) · [Bahasa Malaysia](Step-1-Credentials-ms)
# Step 1 - Credentials
The assignment starts with an account. We use the Share-a-Naan mock instead of the real Instagram, so a changed page or a ban cannot hurt. The profile page of the mock shows the login (the email is the username) and the target accounts to choose `SIMILAR_ACCOUNT` from.
![Target accounts on the profile page](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step1-target-accounts.jpg)
## What we gained
- A safe place to practise: a mock service lets us get the logic right before any real account is at risk.
- Secrets stay out of the code: the password is read from the environment variables `INSTA_USERNAME` and `INSTA_PASSWORD`, and `.env` is in `.gitignore`.
- Constants live in one file, `constants.py`: `SIMILAR_ACCOUNT`, `BASE_URL` and `LOGIN_URL`.
+15
View File
@@ -0,0 +1,15 @@
[English](Step-2-Class) · **Dansk** · [Bahasa Malaysia](Step-2-Class-ms)
# Trin 2 - Klasse
Fordi botten har flere trin, er koden organiseret i en klasse, `InstaFollower`. `__init__` opretter Selenium-driveren. De tre metoder `login()`, `find_followers()` og `follow()` kaldes i den rækkefølge.
![Outline of the InstaFollower class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step2-class-outline.png)
Klassen er Selenium-adapteren i laget `infrastructure`. Et lille use case, `run_bot`, kalder de tre metoder gennem en `Follower`-protokol, så rækkefølgen testes uden en browser.
## Hvad vi lærte
- At samle trin, der deler tilstand (driveren), i en klasse.
- At injicere driveren og sleep-funktionen, så tests bruger en falsk driver og aldrig venter.
- At lukke browseren med en context manager (`with InstaFollower() as follower:`).
+15
View File
@@ -0,0 +1,15 @@
[English](Step-2-Class) · [Dansk](Step-2-Class-da) · **Bahasa Malaysia**
# Langkah 2 - Kelas
Oleh sebab bot mempunyai beberapa langkah, kod disusun dalam sebuah kelas, `InstaFollower`. `__init__` mencipta pemacu Selenium. Tiga kaedah `login()`, `find_followers()` dan `follow()` dipanggil mengikut urutan itu.
![Outline of the InstaFollower class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step2-class-outline.png)
Kelas ini ialah penyesuai Selenium dalam lapisan `infrastructure`. Satu kes guna kecil, `run_bot`, memanggil tiga kaedah melalui protokol `Follower`, jadi urutan diuji tanpa pelayar.
## Apa yang kami peroleh
- Mengumpulkan langkah yang berkongsi keadaan (pemacu) dalam satu kelas.
- Menyuntik pemacu dan fungsi tidur supaya ujian menggunakan pemacu palsu dan tidak menunggu.
- Menutup pelayar dengan context manager (`with InstaFollower() as follower:`).
+15
View File
@@ -0,0 +1,15 @@
**English** · [Dansk](Step-2-Class-da) · [Bahasa Malaysia](Step-2-Class-ms)
# Step 2 - Class
Because the bot has several steps, the code is organised in a class, `InstaFollower`. `__init__` creates the Selenium driver. The three methods `login()`, `find_followers()` and `follow()` are called in that order.
![Outline of the InstaFollower class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step2-class-outline.png)
The class is the Selenium adapter in the `infrastructure` layer. A small use case, `run_bot`, calls the three methods through a `Follower` protocol, so the order is tested without a browser.
## What we gained
- Grouping steps that share state (the driver) in a class.
- Injecting the driver and the sleep function, so tests use a fake driver and never wait.
- Closing the browser with a context manager (`with InstaFollower() as follower:`).
+19
View File
@@ -0,0 +1,19 @@
[English](Step-3-Login) · **Dansk** · [Bahasa Malaysia](Step-3-Login-ms)
# Trin 3 - Login
`login()` åbner loginsiden, udfylder e-mail og adgangskode og trykker på "Log in". Så følger to pop-ups: "Save your login info?" og "Turn on notifications". Begge lukkes med knappen "Not Now".
![Share-a-Naan login page](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-login-page.jpg)
![Save your login info pop-up](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-save-info-popup.jpg)
![Turn on notifications pop-up](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-notifications-popup.jpg)
På den rigtige Instagram ændrer XPaths og selektorer sig ved hvert besøg. Løsningen er at finde en knap på dens tekst, for eksempel `//button[contains(text(), 'Not Now')]`, og ikke på et genereret klassenavn.
## Hvad vi lærte
- At finde elementer på synlig tekst med `contains()` og `text()`, som overlever ændringer i layoutet.
- At vente på et element med `WebDriverWait` i stedet for en fast pause.
- At håndtere valgfrie pop-ups: klik på dem, hvis de findes, og fortsæt, hvis de ikke gør.
+19
View File
@@ -0,0 +1,19 @@
[English](Step-3-Login) · [Dansk](Step-3-Login-da) · **Bahasa Malaysia**
# Langkah 3 - Log masuk
`login()` membuka halaman log masuk, mengisi e-mel dan kata laluan, kemudian menekan "Log in". Dua tetingkap timbul menyusul: "Save your login info?" dan "Turn on notifications". Kedua-duanya ditutup dengan butang "Not Now".
![Share-a-Naan login page](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-login-page.jpg)
![Save your login info pop-up](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-save-info-popup.jpg)
![Turn on notifications pop-up](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-notifications-popup.jpg)
Pada Instagram sebenar, XPath dan pemilih berubah pada setiap lawatan. Penyelesaiannya ialah mencari butang melalui perkataannya, contohnya `//button[contains(text(), 'Not Now')]`, dan bukan melalui nama kelas yang dijana.
## Apa yang kami peroleh
- Mencari elemen melalui teks yang kelihatan dengan `contains()` dan `text()`, yang tahan perubahan reka letak.
- Menunggu elemen dengan `WebDriverWait` dan bukan jeda tetap.
- Mengendalikan tetingkap timbul pilihan: klik jika ada dan teruskan jika tiada.
+19
View File
@@ -0,0 +1,19 @@
**English** · [Dansk](Step-3-Login-da) · [Bahasa Malaysia](Step-3-Login-ms)
# Step 3 - Login
`login()` opens the login page, fills in the email and password and presses "Log in". Two pop-ups follow: "Save your login info?" and "Turn on notifications". Both are closed with the "Not Now" button.
![Share-a-Naan login page](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-login-page.jpg)
![Save your login info pop-up](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-save-info-popup.jpg)
![Turn on notifications pop-up](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step3-notifications-popup.jpg)
On the real Instagram the XPaths and selectors change on every visit. The fix is to find a button by its words, for example `//button[contains(text(), 'Not Now')]`, and not by a generated class name.
## What we gained
- Locating elements by visible text with `contains()` and `text()`, which survives layout changes.
- Waiting for an element with `WebDriverWait` instead of a fixed pause.
- Handling optional pop-ups: click them if present and carry on if not.
+19
View File
@@ -0,0 +1,19 @@
[English](Step-4-Find-Followers) · **Dansk** · [Bahasa Malaysia](Step-4-Find-Followers-ms)
# Trin 4 - Find følgerne
`find_followers()` åbner målkontoens side på `/u/<konto>` og klikker på linket til følgerne. Listen i modalen indlæser først omkring et dusin følgere, så vi ruller for at indlæse resten.
![Profile of the target account](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-target-profile.jpg)
![Followers modal](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-followers-modal.jpg)
Modalen er ikke det element, der ruller. Den rullende del er det indre `followers-scroll`-element, så scriptet ruller det element: `arguments[0].scrollTop = arguments[0].scrollHeight`.
![Followers modal scrolled to the end](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-followers-scrolled.jpg)
## Hvad vi lærte
- At køre JavaScript fra Selenium med `execute_script`.
- At finde det element, der virkelig ruller, ved at undersøge siden i stedet for at rulle hele vinduet.
- At holde pause mellem rulninger, så siden kan indlæse flere poster.
+19
View File
@@ -0,0 +1,19 @@
[English](Step-4-Find-Followers) · [Dansk](Step-4-Find-Followers-da) · **Bahasa Malaysia**
# Langkah 4 - Cari pengikut
`find_followers()` membuka halaman akaun sasaran di `/u/<akaun>` dan mengklik pautan pengikut. Senarai dalam modal pada mulanya memuatkan kira-kira sedozen pengikut, maka kami menatal untuk memuatkan selebihnya.
![Profile of the target account](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-target-profile.jpg)
![Followers modal](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-followers-modal.jpg)
Modal bukan elemen yang menatal. Bahagian yang boleh ditatal ialah elemen dalaman `followers-scroll`, jadi skrip menatal elemen itu: `arguments[0].scrollTop = arguments[0].scrollHeight`.
![Followers modal scrolled to the end](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-followers-scrolled.jpg)
## Apa yang kami peroleh
- Menjalankan JavaScript daripada Selenium dengan `execute_script`.
- Mencari elemen yang benar-benar menatal dengan memeriksa halaman, bukan menatal seluruh tetingkap.
- Berhenti seketika antara tatalan supaya halaman sempat memuatkan lebih banyak entri.
+19
View File
@@ -0,0 +1,19 @@
**English** · [Dansk](Step-4-Find-Followers-da) · [Bahasa Malaysia](Step-4-Find-Followers-ms)
# Step 4 - Find the followers
`find_followers()` opens the page of the target account at `/u/<account>` and clicks the followers link. The list in the modal loads about a dozen followers at first, so we scroll to load the rest.
![Profile of the target account](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-target-profile.jpg)
![Followers modal](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-followers-modal.jpg)
The modal is not the scrolling element. The scrollable part is the inner `followers-scroll` element, so the script scrolls that element: `arguments[0].scrollTop = arguments[0].scrollHeight`.
![Followers modal scrolled to the end](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step4-followers-scrolled.jpg)
## What we gained
- Running JavaScript from Selenium with `execute_script`.
- Finding the element that really scrolls by inspecting the page, instead of scrolling the whole window.
- Pausing between scrolls so the page can load more entries.
+17
View File
@@ -0,0 +1,17 @@
[English](Step-5-Follow) · **Dansk** · [Bahasa Malaysia](Step-5-Follow-ms)
# Trin 5 - Følg
`follow()` finder hver "Follow"-knap i følgerlisten og klikker på den med et sekunds pause mellem klikkene, så botten ligner en maskine mindre. Knapper, der allerede siger "Following", springes over.
Hvis et klik opfanges af pop-up'en "Unfollow?" (en konto, man allerede følger), rejser Selenium `ElementClickInterceptedException`. Botten klikker på "Cancel" og fortsætter med næste knap.
![Unfollow pop-up with a Cancel button](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step5-unfollow-popup.jpg)
![Followers after the follow loop](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step5-followed.jpg)
## Hvad vi lærte
- At håndtere en bestemt undtagelse med vilje: ingen bar `except`, kun det tilfælde, opgaven beskriver.
- At teste fejlstien med en falsk driver, der rejser undtagelsen.
- At vise respekt for tjenesten: et sekund mellem klik og en mock-tjeneste med en "Reset Share-a-Naan"-knap til rene genkørsler.
+17
View File
@@ -0,0 +1,17 @@
[English](Step-5-Follow) · [Dansk](Step-5-Follow-da) · **Bahasa Malaysia**
# Langkah 5 - Ikut
`follow()` mencari setiap butang "Follow" dalam senarai pengikut dan mengkliknya dengan jeda satu saat antara klik, supaya bot kurang kelihatan seperti mesin. Butang yang sudah menyatakan "Following" dilangkau.
Jika klik dipintas oleh tetingkap timbul "Unfollow?" (akaun yang sudah diikuti), Selenium menimbulkan `ElementClickInterceptedException`. Bot mengklik "Cancel" dan meneruskan dengan butang seterusnya.
![Unfollow pop-up with a Cancel button](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step5-unfollow-popup.jpg)
![Followers after the follow loop](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step5-followed.jpg)
## Apa yang kami peroleh
- Mengendalikan pengecualian tertentu dengan sengaja: tiada `except` kosong, hanya kes yang diterangkan dalam tugasan.
- Menguji laluan ralat itu dengan pemacu palsu yang menimbulkan pengecualian.
- Menghormati perkhidmatan: satu saat antara klik, dan perkhidmatan tiruan dengan butang "Reset Share-a-Naan" untuk larian semula yang bersih.
+17
View File
@@ -0,0 +1,17 @@
**English** · [Dansk](Step-5-Follow-da) · [Bahasa Malaysia](Step-5-Follow-ms)
# Step 5 - Follow
`follow()` finds every "Follow" button in the followers list and clicks it with a one second pause between clicks, so the bot looks less like a machine. Buttons that already say "Following" are skipped.
If a click is intercepted by the "Unfollow?" pop-up (an account that is already followed), Selenium raises `ElementClickInterceptedException`. The bot clicks "Cancel" and carries on with the next button.
![Unfollow pop-up with a Cancel button](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step5-unfollow-popup.jpg)
![Followers after the follow loop](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/raw/branch/main/docs/images/step5-followed.jpg)
## What we gained
- Handling a specific exception on purpose: no bare `except`, and only the case the assignment describes.
- Testing that error path with a fake driver that raises the exception.
- Respecting the service: one second between clicks, and a mock service with a "Reset Share-a-Naan" button for clean re-runs.
+14
View File
@@ -0,0 +1,14 @@
**Instagram Followers**
- [Home](Home)
- [Step 1 - Credentials](Step-1-Credentials)
- [Step 2 - Class](Step-2-Class)
- [Step 3 - Login](Step-3-Login)
- [Step 4 - Find the followers](Step-4-Find-Followers)
- [Step 5 - Follow](Step-5-Follow)
**Languages**
- English: [Home](Home)
- Dansk: [Hjem](Home-da)
- Bahasa Melayu: [Utama](Home-ms)
+10 -4
View File
@@ -9,17 +9,23 @@ description = "Day 52 of 100 Days of Code: a Selenium bot that follows the follo
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = ["selenium>=4.20"]
dependencies = ["selenium==4.52.0"]
[project.optional-dependencies]
dev = ["pytest>=8"]
dev = ["pytest==9.2.0", "ruff==0.17.3", "mypy==2.4.1"]
[project.scripts]
instagram-followers = "instagram_followers.main:main"
instagram-followers = "instagram_followers.presentation.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
pythonpath = ["src", "."]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
+1
View File
@@ -0,0 +1 @@
"""Selenium bot that follows the followers of a similar account."""
@@ -0,0 +1 @@
"""Layer package."""
@@ -0,0 +1,15 @@
## @file ports.py
# @brief Ports the use cases need from the outside.
from typing import Protocol
## @brief Something that can log in and follow the followers of an account.
class Follower(Protocol):
## @brief Log in to the service.
def login(self) -> None: ...
## @brief Open the list of followers to follow.
def find_followers(self) -> None: ...
## @brief Follow everyone in the opened list.
def follow(self) -> None: ...
@@ -0,0 +1,11 @@
## @file run_bot.py
# @brief Use case: log in, find the followers and follow them.
from instagram_followers.application.ports import Follower
## @brief Run the three steps of the bot in order.
# @param follower The follower implementation to drive.
def run_bot(follower: Follower) -> None:
follower.login()
follower.find_followers()
follower.follow()
+50
View File
@@ -0,0 +1,50 @@
## @file constants.py
# @brief All constants of the follower bot. No credential value is held here.
## Account whose followers are followed.
SIMILAR_ACCOUNT = "chefsteps"
## Root of the Share-a-Naan mock service.
BASE_URL = "https://app.100daysofpython.dev/services/share-a-naan"
## Login page.
LOGIN_URL = f"{BASE_URL}/login"
## Account page template; fill with the account name.
ACCOUNT_URL_TEMPLATE = f"{BASE_URL}/u/{{account}}"
## Environment variables the credentials are read from at runtime.
ENV_USERNAME = "INSTA_USERNAME"
ENV_PASSWORD = "INSTA_PASSWORD"
## Locators below were verified against the live mock on 2026-11-04.
USERNAME_FIELD_ID = "username"
PASSWORD_FIELD_ID = "password"
SUBMIT_BUTTON_XPATH = "//button[@type='submit']"
## Both post-login pop-ups ("Save your login info?", notifications) use this button.
NOT_NOW_BUTTON_XPATH = "//button[contains(@class, 'naan-popup-dismiss')]"
FOLLOWERS_LINK_XPATH = "//a[contains(@class, 'naan-followers-link')]"
## The followers modal; the hidden naan-post-modal dialog does not match.
FOLLOWERS_MODAL_XPATH = "//div[@role='dialog' and contains(@class, 'naan-modal')]"
## The scrollable list inside the modal (the modal itself has overflow hidden).
FOLLOWERS_SCROLL_XPATH = "//div[contains(@class, 'followers-scroll')]"
## Relative to the scroll container, so the profile header Follow button never matches.
FOLLOW_BUTTON_XPATH = (
".//button[contains(@class, 'naan-follow-btn') and normalize-space(text())='Follow']"
)
CANCEL_BUTTON_XPATH = "//button[contains(@class, 'naan-unfollow-cancel')]"
## UNVERIFIED: no cookie banner was seen on the live mock; the dismissal is optional.
COOKIE_BUTTON_XPATH = (
"//button[contains(text(), 'Accept') or contains(text(), 'Allow') "
"or contains(text(), 'Agree') or contains(text(), 'OK')]"
)
## Timing, in seconds.
FOLLOW_DELAY = 1
SCROLL_DELAY = 1
PAGE_SETTLE_DELAY = 2
WAIT_TIMEOUT = 10
## Number of times the followers list is scrolled to load more entries.
SCROLL_COUNT = 5
## Script that scrolls the element passed as arguments[0] to its bottom.
SCROLL_CONTAINER_SCRIPT = "arguments[0].scrollTop = arguments[0].scrollHeight"
@@ -0,0 +1 @@
"""Layer package."""
@@ -0,0 +1,10 @@
## @file credentials.py
# @brief Login credentials value object.
from dataclasses import dataclass, field
## @brief Username and password used to log in; the password is hidden from repr.
@dataclass(frozen=True, slots=True)
class Credentials:
username: str
password: str = field(repr=False)
+7
View File
@@ -0,0 +1,7 @@
## @file errors.py
# @brief Errors raised by the business rules.
## @brief Raised when a required credential is not set in the environment.
class MissingCredentialsError(RuntimeError):
pass
@@ -0,0 +1 @@
"""Layer package."""
@@ -0,0 +1,24 @@
## @file config.py
# @brief Reads configuration (credentials) from the environment.
import os
from collections.abc import Mapping
from instagram_followers import constants
from instagram_followers.domain.credentials import Credentials
from instagram_followers.domain.errors import MissingCredentialsError
## @brief Read the login credentials from the environment.
# @param environ Mapping to read from; defaults to os.environ.
# @return The credentials.
# @throws MissingCredentialsError if either variable is missing or empty.
def read_credentials(environ: Mapping[str, str] | None = None) -> Credentials:
source = os.environ if environ is None else environ
missing = [
name for name in (constants.ENV_USERNAME, constants.ENV_PASSWORD) if not source.get(name)
]
if missing:
raise MissingCredentialsError(f"Missing environment variable(s): {', '.join(missing)}")
return Credentials(
username=source[constants.ENV_USERNAME], password=source[constants.ENV_PASSWORD]
)
@@ -0,0 +1,116 @@
## @file insta_follower.py
# @brief Selenium bot that logs in and follows the followers of an account.
import time
from collections.abc import Callable
from types import TracebackType
from typing import Self
from selenium import webdriver
from selenium.common.exceptions import ElementClickInterceptedException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from instagram_followers import constants
from instagram_followers.infrastructure.config import read_credentials
## @brief Bot that follows the followers of constants.SIMILAR_ACCOUNT.
class InstaFollower:
## @brief Create the bot.
# @param driver Optional WebDriver (injected in tests); Chrome if None.
# @param headless Run Chrome without a window when creating the driver.
# @param sleep Function used to pause; injectable so tests do not wait.
def __init__(
self,
driver: WebDriver | None = None,
headless: bool = False,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self._owns_driver = driver is None
self._driver = driver if driver is not None else self._create_driver(headless)
self._sleep = sleep
## @brief Enter the context.
# @return This bot.
def __enter__(self) -> Self:
return self
## @brief Leave the context and close the driver if this bot created it.
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.close()
## @brief Quit the browser; an injected driver is left to its owner.
def close(self) -> None:
if self._owns_driver:
self._driver.quit()
@staticmethod
def _create_driver(headless: bool) -> WebDriver:
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless=new")
return webdriver.Chrome(options=options)
def _click_if_present(self, xpath: str) -> bool:
elements = self._driver.find_elements(By.XPATH, xpath)
if not elements:
return False
elements[0].click()
return True
## @brief Log in, dismissing the cookie warning and any pop-ups.
# @throws MissingCredentialsError if the credentials are not in the environment.
def login(self) -> None:
credentials = read_credentials()
self._driver.get(constants.LOGIN_URL)
self._click_if_present(constants.COOKIE_BUTTON_XPATH)
wait = WebDriverWait(self._driver, constants.WAIT_TIMEOUT)
username_field = wait.until(
EC.presence_of_element_located((By.ID, constants.USERNAME_FIELD_ID))
)
username_field.send_keys(credentials.username)
self._driver.find_element(By.ID, constants.PASSWORD_FIELD_ID).send_keys(
credentials.password
)
self._driver.find_element(By.XPATH, constants.SUBMIT_BUTTON_XPATH).click()
self._sleep(constants.PAGE_SETTLE_DELAY)
# "Save login info", then notifications: both are "Not Now" buttons.
for _ in range(2):
self._click_if_present(constants.NOT_NOW_BUTTON_XPATH)
## @brief Open the similar account and scroll its followers list.
def find_followers(self) -> None:
account_url = constants.ACCOUNT_URL_TEMPLATE.format(account=constants.SIMILAR_ACCOUNT)
self._driver.get(account_url)
self._sleep(constants.PAGE_SETTLE_DELAY)
wait = WebDriverWait(self._driver, constants.WAIT_TIMEOUT)
wait.until(
EC.presence_of_element_located((By.XPATH, constants.FOLLOWERS_LINK_XPATH))
).click()
wait.until(EC.presence_of_element_located((By.XPATH, constants.FOLLOWERS_MODAL_XPATH)))
scroll_container = wait.until(
EC.presence_of_element_located((By.XPATH, constants.FOLLOWERS_SCROLL_XPATH))
)
for _ in range(constants.SCROLL_COUNT):
# The modal has overflow hidden; only the list inside it scrolls.
self._driver.execute_script(constants.SCROLL_CONTAINER_SCRIPT, scroll_container)
self._sleep(constants.SCROLL_DELAY)
## @brief Click every Follow button in the followers list.
# An interception means an unfollow confirmation opened (the account is
# already followed); its Cancel button is clicked and the loop goes on.
def follow(self) -> None:
container = self._driver.find_element(By.XPATH, constants.FOLLOWERS_SCROLL_XPATH)
for button in container.find_elements(By.XPATH, constants.FOLLOW_BUTTON_XPATH):
try:
button.click()
except ElementClickInterceptedException:
self._click_if_present(constants.CANCEL_BUTTON_XPATH)
self._sleep(constants.FOLLOW_DELAY)
@@ -0,0 +1 @@
"""Layer package."""
@@ -0,0 +1,14 @@
## @file main.py
# @brief Composition root: wires and runs the follower bot.
from instagram_followers.application.run_bot import run_bot
from instagram_followers.infrastructure.insta_follower import InstaFollower
## @brief Build the Selenium follower and run the bot.
def main() -> None:
with InstaFollower() as follower:
run_bot(follower)
if __name__ == "__main__":
main()
View File
View File
+52
View File
@@ -0,0 +1,52 @@
"""Hand-written fakes of the Selenium driver and elements."""
from typing import Any
from selenium.common.exceptions import ElementClickInterceptedException
class FakeElement:
def __init__(
self, intercepted: bool = False, children: list["FakeElement"] | None = None
) -> None:
self.intercepted = intercepted
self.children = children or []
self.clicks = 0
self.typed: list[str] = []
def click(self) -> None:
if self.intercepted:
raise ElementClickInterceptedException("blocked")
self.clicks += 1
def send_keys(self, text: str) -> None:
self.typed.append(text)
def find_elements(self, by: str, value: str) -> list["FakeElement"]:
return self.children
class FakeDriver:
def __init__(self, present: dict[str, list[FakeElement]]) -> None:
self.present = present
self.visited: list[str] = []
self.scripts: list[tuple[str, Any]] = []
self.quit_calls = 0
def quit(self) -> None:
self.quit_calls += 1
def get(self, url: str) -> None:
self.visited.append(url)
def find_elements(self, by: str, value: str) -> list[FakeElement]:
return self.present.get(value, [])
def find_element(self, by: str, value: str) -> FakeElement:
found = self.find_elements(by, value)
if not found:
raise LookupError(value)
return found[0]
def execute_script(self, script: str, *args: Any) -> None:
self.scripts.append((script, args[0]))
View File
@@ -0,0 +1,30 @@
from typing import Any
import pytest
from instagram_followers import constants
from instagram_followers.application.run_bot import run_bot
from instagram_followers.infrastructure.insta_follower import InstaFollower
from tests.fakes import FakeDriver, FakeElement
def test_run_bot_drives_selenium_follower_through_all_steps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(constants.ENV_USERNAME, "someone")
monkeypatch.setenv(constants.ENV_PASSWORD, "test-value")
button = FakeElement()
driver = FakeDriver(
{
constants.USERNAME_FIELD_ID: [FakeElement()],
constants.PASSWORD_FIELD_ID: [FakeElement()],
constants.SUBMIT_BUTTON_XPATH: [FakeElement()],
constants.FOLLOWERS_LINK_XPATH: [FakeElement()],
constants.FOLLOWERS_MODAL_XPATH: [FakeElement()],
constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=[button])],
}
)
fake: Any = driver
run_bot(InstaFollower(driver=fake, sleep=lambda _seconds: None))
assert driver.visited[0] == constants.LOGIN_URL
assert button.clicks == 1
View File
View File
+21
View File
@@ -0,0 +1,21 @@
from instagram_followers.application.run_bot import run_bot
class FakeFollower:
def __init__(self) -> None:
self.calls: list[str] = []
def login(self) -> None:
self.calls.append("login")
def find_followers(self) -> None:
self.calls.append("find_followers")
def follow(self) -> None:
self.calls.append("follow")
def test_run_bot_calls_steps_in_order() -> None:
follower = FakeFollower()
run_bot(follower)
assert follower.calls == ["login", "find_followers", "follow"]
View File
+5
View File
@@ -0,0 +1,5 @@
from instagram_followers.domain.credentials import Credentials
def test_credentials_repr_hides_password() -> None:
assert "test-value" not in repr(Credentials("u", "test-value"))
+21
View File
@@ -0,0 +1,21 @@
import pytest
from instagram_followers import constants
from instagram_followers.domain.errors import MissingCredentialsError
from instagram_followers.infrastructure.config import read_credentials
def test_read_credentials_returns_values_from_mapping() -> None:
environ = {constants.ENV_USERNAME: "u", constants.ENV_PASSWORD: "test-value"}
credentials = read_credentials(environ)
assert (credentials.username, credentials.password) == ("u", "test-value")
def test_read_credentials_rejects_empty_value() -> None:
with pytest.raises(MissingCredentialsError, match="INSTA_PASSWORD"):
read_credentials({constants.ENV_USERNAME: "u", constants.ENV_PASSWORD: ""})
def test_read_credentials_names_every_missing_variable() -> None:
with pytest.raises(MissingCredentialsError, match="INSTA_USERNAME, INSTA_PASSWORD"):
read_credentials({})
@@ -0,0 +1,150 @@
from pathlib import Path
from typing import Any
import pytest
from instagram_followers import constants
from instagram_followers.domain.errors import MissingCredentialsError
from instagram_followers.infrastructure.insta_follower import InstaFollower
from tests.fakes import FakeDriver, FakeElement
def make_bot(driver: FakeDriver, sleeps: list[float]) -> InstaFollower:
fake: Any = driver
return InstaFollower(driver=fake, sleep=sleeps.append)
@pytest.fixture
def credentials(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(constants.ENV_USERNAME, "someone")
monkeypatch.setenv(constants.ENV_PASSWORD, "test-value")
def login_form() -> dict[str, list[FakeElement]]:
return {
constants.USERNAME_FIELD_ID: [FakeElement()],
constants.PASSWORD_FIELD_ID: [FakeElement()],
constants.SUBMIT_BUTTON_XPATH: [FakeElement()],
}
def test_login_fills_form_and_submits_without_popups(credentials: None) -> None:
form = login_form()
driver = FakeDriver(form)
make_bot(driver, []).login()
assert driver.visited == [constants.LOGIN_URL]
assert form[constants.USERNAME_FIELD_ID][0].typed == ["someone"]
assert form[constants.PASSWORD_FIELD_ID][0].typed == ["test-value"]
assert form[constants.SUBMIT_BUTTON_XPATH][0].clicks == 1
def test_login_dismisses_popups_when_present(credentials: None) -> None:
cookie, not_now = FakeElement(), FakeElement()
form = login_form()
form[constants.COOKIE_BUTTON_XPATH] = [cookie]
form[constants.NOT_NOW_BUTTON_XPATH] = [not_now]
make_bot(FakeDriver(form), []).login()
assert cookie.clicks == 1
assert not_now.clicks == 2
def test_login_raises_when_credentials_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(constants.ENV_USERNAME, raising=False)
monkeypatch.delenv(constants.ENV_PASSWORD, raising=False)
with pytest.raises(MissingCredentialsError, match="INSTA_USERNAME"):
make_bot(FakeDriver({}), []).login()
def test_find_followers_scrolls_the_scroll_container_n_times() -> None:
modal, scroll_container, link = FakeElement(), FakeElement(), FakeElement()
driver = FakeDriver(
{
constants.FOLLOWERS_LINK_XPATH: [link],
constants.FOLLOWERS_MODAL_XPATH: [modal],
constants.FOLLOWERS_SCROLL_XPATH: [scroll_container],
}
)
make_bot(driver, []).find_followers()
expected_url = constants.ACCOUNT_URL_TEMPLATE.format(account=constants.SIMILAR_ACCOUNT)
assert driver.visited == [expected_url]
assert link.clicks == 1
assert (
driver.scripts
== [(constants.SCROLL_CONTAINER_SCRIPT, scroll_container)] * constants.SCROLL_COUNT
)
assert all(target is not modal for _, target in driver.scripts)
def test_follow_clicks_every_button_with_one_second_delay() -> None:
buttons = [FakeElement(), FakeElement(), FakeElement()]
driver = FakeDriver({constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=buttons)]})
sleeps: list[float] = []
make_bot(driver, sleeps).follow()
assert [b.clicks for b in buttons] == [1, 1, 1]
assert sleeps == [1, 1, 1]
def test_follow_cancels_popup_and_continues_when_click_intercepted() -> None:
cancel = FakeElement()
buttons = [FakeElement(), FakeElement(intercepted=True), FakeElement()]
driver = FakeDriver(
{
constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=buttons)],
constants.CANCEL_BUTTON_XPATH: [cancel],
}
)
make_bot(driver, []).follow()
assert cancel.clicks == 1
assert buttons[2].clicks == 1
def test_constants_hold_no_credential_value() -> None:
source = Path(constants.__file__).read_text(encoding="utf-8")
assert not hasattr(constants, "PASSWORD")
assert not hasattr(constants, "USERNAME")
assert "PASSWORD =" not in source.replace("ENV_PASSWORD =", "")
def test_context_exit_quits_driver_created_by_the_bot(monkeypatch: pytest.MonkeyPatch) -> None:
driver = FakeDriver({})
fake: Any = driver
monkeypatch.setattr(InstaFollower, "_create_driver", staticmethod(lambda headless: fake))
with InstaFollower(sleep=lambda _seconds: None) as bot:
assert isinstance(bot, InstaFollower)
assert driver.quit_calls == 1
def test_context_exit_leaves_injected_driver_open() -> None:
driver = FakeDriver({})
with make_bot(driver, []):
pass
assert driver.quit_calls == 0
def test_context_exit_quits_owned_driver_when_body_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
driver = FakeDriver({})
fake: Any = driver
monkeypatch.setattr(InstaFollower, "_create_driver", staticmethod(lambda headless: fake))
with pytest.raises(RuntimeError), InstaFollower():
raise RuntimeError("boom")
assert driver.quit_calls == 1
def test_follow_never_clicks_the_profile_follow_button() -> None:
profile_button, row_button = FakeElement(), FakeElement()
driver = FakeDriver(
{
"//profile-follow-button": [profile_button],
constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=[row_button])],
}
)
make_bot(driver, []).follow()
assert profile_button.clicks == 0
assert row_button.clicks == 1
def test_follow_button_locator_is_relative_and_matches_exact_follow_text() -> None:
assert constants.FOLLOW_BUTTON_XPATH.startswith(".//")
assert "normalize-space(text())='Follow'" in constants.FOLLOW_BUTTON_XPATH