Compare commits

..
3 Commits
Author SHA1 Message Date
feder-cr b32b86f30a counters: 2026-11-10, applications sent at 3 2026-11-10 00:34:56 +00:00
feder-cr bb0d05be0b counters: 2026-11-09, applications sent at 0 2026-11-09 16:31:19 +00:00
Federico EliaandClaude Opus 5.5 44d7b9b4b6 badges: the branch the nightly counters write to
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:49 +01:00
14 changed files with 40 additions and 444 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
# The pre-push hook is a shell script, and git must never hand it to a POSIX
# shell with CRLF line endings: `/bin/sh^M: bad interpreter` is how a gate stops
# running on a machine where nothing else looks wrong. Git-for-Windows' sh is
# the one that runs it on a Windows clone with core.autocrlf=true.
.githooks/** text eol=lf
-55
View File
@@ -1,55 +0,0 @@
#!/bin/sh
# Pre-push gate. THE POLICY IS NOT IN THIS FILE - it is `invisible_core.hooks`,
# and this stub exists only to find an interpreter and hand over stdin.
#
# It used to be here, in three repositories, as 743 lines of shell that were 207
# identical lines out of 211 between two of them. Nine of the eleven differences
# were accidental: the core ran no pytest, the core set a variable three times
# that only the other two ever read, the core resolved an interpreter and then
# called bare `python` anyway. Every fix landed in whichever copy was open.
#
# What this repository wants gated is declared in its own pyproject, under
# [tool.invisible.hooks]. That is the only per-repo part, and it is data.
#
# Install once with:
# git config core.hooksPath .githooks
# A test in this repo fails in an uninstalled checkout, so nobody has to
# remember. Bypass a known-broken WIP push with `git push --no-verify`; that is
# the only escape hatch, and never for a branch that feeds a release.
# No `set -e`: exit codes here have to be READ and explained, not turned into a
# silent abort. A gate that dies without a message is the worst shape of all.
# Run from the repository root regardless of where `git push` was invoked.
cd "$(dirname "$0")/.." || exit 1
python_bin=""
for candidate in "$INVISIBLE_GATE_PYTHON" python3 python py; do
[ -n "$candidate" ] || continue
if command -v "$candidate" >/dev/null 2>&1; then
python_bin="$candidate"
break
fi
done
if [ -z "$python_bin" ]; then
echo "[pre-push] REFUSED - no python on PATH, so no gate can run." >&2
echo "[pre-push] Nothing must be pushed past a hook that could not check it." >&2
echo "[pre-push] Set INVISIBLE_GATE_PYTHON to an interpreter and push again." >&2
exit 1
fi
# `src` first so a checkout gates itself with the policy in the tree being
# pushed. os.getcwd() rather than $PWD on purpose: under git-bash on Windows
# $PWD is /c/... and Python does not resolve that mount.
# exec, so stdin (the refs git is pushing) reaches the policy untouched.
exec "$python_bin" -c 'import os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "src"))
try:
from invisible_core.hooks import main
except ImportError as exc:
sys.exit("[pre-push] REFUSED - cannot import invisible_core.hooks (%s).\n"
"[pre-push] The pre-push policy lives in invisible-core. This repo is\n"
"[pre-push] either that package (so src/ is not where it should be) or\n"
"[pre-push] pins it exactly (so install it). Either way, nothing is\n"
"[pre-push] pushed past a hook that could not check it." % exc)
sys.exit(main())'
-119
View File
@@ -1,119 +0,0 @@
name: tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# `gate` is the ONLY context the branch protection requires: it depends on every
# job, so its name never drifts when the matrix changes. Unlike its siblings it
# accepts nothing but success, because no job here is ever skipped on purpose:
# a skip would mean a job that did not run, which reads exactly like one that
# passed.
defaults:
run:
shell: bash
jobs:
english:
name: the repository is English
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: install the pinned package, which carries the gate
run: |
python -m pip install --upgrade pip
pip install -e .
- name: check the prose
run: |
python -m invisible_core.english --selftest
python -m invisible_core.english
claims:
name: the README promises nothing it cannot support
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: install the pinned package, which carries the gate
run: |
python -m pip install --upgrade pip
pip install -e .
- name: check the claims
run: |
python -m invisible_core.claims --selftest
python -m invisible_core.claims
unit:
name: pytest (${{ matrix.os }}, py${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
# Every version `requires-python = ">=3.11"` promises and the pinned
# package declares, on ubuntu; Windows on 3.12.
python: ["3.11", "3.12", "3.13"]
exclude:
- { os: windows-latest, python: "3.11" }
- { os: windows-latest, python: "3.13" }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- name: install the package and its dev extras
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: run pytest
run: |
set -o pipefail # or tee would swallow pytest's exit code
pytest tests/ -v --tb=short -rs | tee tests-report.txt
# pytest's exit code cannot tell "everything passed" from "the selection
# dropped everything". The suite is four tests, so all four must run.
- name: refuse a run in which the suite deselected itself
run: |
python - <<'PY'
import pathlib, re, sys
text = pathlib.Path("tests-report.txt").read_text(encoding="utf-8", errors="ignore")
m = re.search(r"(\d+) passed", text)
passed = int(m.group(1)) if m else 0
if passed < 4:
print(f"only {passed} test(s) ran (expected 4)", file=sys.stderr)
sys.exit(1)
print(f"{passed} tests ran")
PY
gate:
needs: [unit, english, claims]
if: always()
runs-on: ubuntu-latest
steps:
- name: refuse unless every job succeeded
run: |
echo "unit: ${{ needs.unit.result }}"
echo "english: ${{ needs.english.result }}"
echo "claims: ${{ needs.claims.result }}"
for result in "${{ needs.unit.result }}" "${{ needs.english.result }}" "${{ needs.claims.result }}"; do
if [ "$result" != "success" ]; then
echo "a job did not succeed: $result"
exit 1
fi
done
echo "ok"
-8
View File
@@ -1,8 +0,0 @@
__pycache__/
*.py[cod]
.venv/
build/
dist/
*.egg-info/
.pytest_cache/
.env
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 feder-cr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -65
View File
@@ -1,67 +1,7 @@
<div align="center">
# badges
# dots
Written every night by .github/workflows/counters.yml on main: the applications AIHawk has sent, for all its users together.
**Every AI agent is a model and a browser.<br>You can swap the model with one flag. The browser is what the website sees.**
</div>
---
Windows, in PowerShell:
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
$env:Path = "$env:USERPROFILE\.local\bin;$env:Path"
uvx --from git+https://github.com/feder-cr/dots dots --openrouter-key sk-or-...
```
Linux:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
uvx --from git+https://github.com/feder-cr/dots dots --openrouter-key sk-or-...
```
Then open **http://127.0.0.1:8765**. The conversation on the left, the browser on the right, live.
## It all comes down to the browser
When a web agent fails, the model is rarely why. The page never loaded, a
challenge appeared, the login expired, the click did not land. All of that
happens in the browser, before the model gets to think.
So dots is built around one:
- **A real Firefox engine, patched in C++.** The fingerprint is decided inside
the engine, not painted over with JavaScript that a page can inspect.
- **One identity per seed.** Screen, fonts, GPU, timezone and language agree
with each other, and `--seed` gives back the same person on every run.
- **Nothing for a page to find.** No WebDriver flag, no DevTools protocol, no
automation globals in the page.
- **A person's hands.** The pointer travels to what it clicks and keys are
pressed one at a time, so every event the page receives is a trusted one.
- **A browser that remembers.** `--profile-dir` keeps logins and cookies from
one run to the next.
- **Where it connects from is who it is.** With `--proxy`, the timezone and the
language follow the exit.
The model is any model on OpenRouter, and `--model` changes it.
## What to ask it
> Go to `<paste the URL>`. One way, Milan to Lisbon, economy, one adult. Check
> every date from the 12th to the 16th of next month and read the cheapest fare
> for each day. If a date has no availability, say so. Do not guess a number.
## The same browser, in your own assistant
Claude Code, Codex, Gemini CLI or any MCP client:
[invisible_playwright_mcp](https://github.com/feder-cr/invisible_playwright_mcp)
gives them this browser as a server. `dots` is its interface, and `dots --help`
lists every option.
---
Not affiliated with OpenAI. MIT licensed.
- counters/history.csv: every counter, once a day
- counters/applications_daily.csv: the applications of each measured day
- docs/badges/applications.svg: the README badge
+2
View File
@@ -0,0 +1,2 @@
day_utc,sent,submitted,unconfirmed,refused,blocked,failed
2026-11-09,3,1,2,3,6,4
1 day_utc sent submitted unconfirmed refused blocked failed
2 2026-11-09 3 1 2 3 6 4
+17
View File
@@ -0,0 +1,17 @@
date,key,value
2026-11-09,applications,0
2026-11-09,blocked,0
2026-11-09,failed,0
2026-11-09,refused,0
2026-11-09,sent,0
2026-11-09,stars,32419
2026-11-09,submitted,0
2026-11-09,unconfirmed,0
2026-11-10,applications,16
2026-11-10,blocked,6
2026-11-10,failed,4
2026-11-10,refused,3
2026-11-10,sent,3
2026-11-10,stars,32430
2026-11-10,submitted,1
2026-11-10,unconfirmed,2
1 date key value
2 2026-11-09 applications 0
3 2026-11-09 blocked 0
4 2026-11-09 failed 0
5 2026-11-09 refused 0
6 2026-11-09 sent 0
7 2026-11-09 stars 32419
8 2026-11-09 submitted 0
9 2026-11-09 unconfirmed 0
10 2026-11-10 applications 16
11 2026-11-10 blocked 6
12 2026-11-10 failed 4
13 2026-11-10 refused 3
14 2026-11-10 sent 3
15 2026-11-10 stars 32430
16 2026-11-10 submitted 1
17 2026-11-10 unconfirmed 2
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" role="img" aria-label="applications sent: 3">
<title>applications sent: 3</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="126" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="113" height="20" fill="#555"/>
<rect x="113" width="13" height="20" fill="#2ea44f"/>
<rect width="126" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="56.5" y="15" fill="#010101" fill-opacity=".3">applications sent</text>
<text x="56.5" y="14">applications sent</text>
<text x="119.5" y="15" fill="#010101" fill-opacity=".3">3</text>
<text x="119.5" y="14">3</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 886 B

-67
View File
@@ -1,67 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "dots"
version = "0.1.0"
description = "Open-source dots for the web: an agent with its own browser, one that does not get blocked."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "feder-cr", email = "[email protected]" }]
keywords = ["dots", "browser-agent", "web-agent", "ai-agent", "anti-detect", "firefox", "openrouter"]
classifiers = [
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
# ⛔ THE WHOLE PRODUCT IS THIS LINE. `dots` is invisible-playwright-mcp's
# interface under another name: the loop, the browser and the page all
# come from there, and nothing here re-implements any of it. An EXACT pin
# and not a floor, because `cli.py` calls into that package's command
# group, which is not a published API: a new release is taken on purpose,
# with `tests/test_entry.py` run against it, never picked up by a resolver.
"invisible-playwright-mcp==0.70.2",
]
[project.urls]
Homepage = "https://github.com/feder-cr/dots"
Repository = "https://github.com/feder-cr/dots"
"Use it from your assistant" = "https://github.com/feder-cr/invisible_playwright_mcp"
[project.scripts]
dots = "dots.cli:main"
[project.optional-dependencies]
dev = ["pytest>=7"]
[tool.pytest.ini_options]
markers = [
"unit: pure logic - no I/O, no network, no subprocess, no browser",
"integration: runs the real command in a subprocess - no network, no browser",
]
addopts = "--strict-markers"
pythonpath = ["src"]
[tool.hatch.build.targets.wheel]
packages = ["src/dots"]
# Which pre-push gates this repository wants: `.githooks/pre-push` hands over
# to invisible_core.hooks, which arrives here through the pinned package.
# `pin` is off because this repository pins invisible-playwright-mcp, not the
# core, and there are no release tags because nothing is published to an index:
# the name `dots` on PyPI belongs to an unrelated project.
[tool.invisible.hooks]
pytest = true
pin = false
english = true
identity = true
release_tags = []
[tool.invisible.english]
excluded = []
-1
View File
@@ -1 +0,0 @@
"""dots: invisible-playwright-mcp's interface, under the name people search for."""
-3
View File
@@ -1,3 +0,0 @@
from .cli import main
main()
-21
View File
@@ -1,21 +0,0 @@
"""`dots` is `invisible-playwright-mcp ui`, and nothing else.
It goes through the package's command GROUP rather than calling the `ui`
command directly, because the group is where the `.env` beside the command is
read and where an old session directory is carried over. Calling `ui` on its
own would skip both, and a key kept in `.env` would simply not be found.
"""
from __future__ import annotations
import sys
#: The one subcommand this program is. Every option after it is the
#: interface's own, so `dots --help` lists exactly what `ui --help` does.
SUBCOMMAND = "ui"
def main(argv: list[str] | None = None) -> None:
from invisible_playwright_mcp.cli import main as group
args = list(sys.argv[1:] if argv is None else argv)
group(args=[SUBCOMMAND, *args], prog_name="dots")
-79
View File
@@ -1,79 +0,0 @@
"""`dots` is the interface of the pinned invisible-playwright-mcp, and only that.
These tests are what a bump of the pin is judged by: the package's command
group is not a published API, so the day it changes shape is the day one of
these goes red.
"""
from __future__ import annotations
import importlib.metadata
import os
import re
import subprocess
import sys
import tomllib
from pathlib import Path
import pytest
import dots.cli
ROOT = Path(__file__).resolve().parents[1]
def _run(*args: str, cwd: Path, env: dict | None = None) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "dots", *args],
cwd=cwd, env=env, capture_output=True, text=True, timeout=120,
)
def _clean_env(home: Path) -> dict:
"""This environment without a model key, and with its own data directory."""
env = {k: v for k, v in os.environ.items()
if k.upper() not in {"OPENROUTER_API_KEY", "OPENAI_API_KEY"}}
env["INVISIBLE_MCP_HOME"] = str(home)
env["PYTHONPATH"] = str(ROOT / "src")
return env
@pytest.mark.unit
def test_the_group_is_called_with_ui_in_front(monkeypatch):
# Through the GROUP, because the group is what reads `.env`. A `dots` that
# called the `ui` command on its own would pass every other test here and
# never find a key kept in a file.
seen = {}
def group(*, args, prog_name):
seen["args"], seen["prog_name"] = args, prog_name
monkeypatch.setattr("invisible_playwright_mcp.cli.main", group)
dots.cli.main(["--port", "9000", "--headed"])
assert seen == {"args": ["ui", "--port", "9000", "--headed"], "prog_name": "dots"}
@pytest.mark.integration
def test_help_is_the_interface_help(tmp_path):
done = _run("--help", cwd=tmp_path, env=_clean_env(tmp_path / "home"))
assert done.returncode == 0, done.stderr
assert "Usage: dots" in done.stdout
for option in ("--openrouter-key", "--model", "--proxy", "--seed", "--profile-dir"):
assert option in done.stdout
@pytest.mark.integration
def test_without_a_key_it_refuses_before_starting_anything(tmp_path):
done = _run(cwd=tmp_path, env=_clean_env(tmp_path / "home"))
assert done.returncode != 0
assert "OpenRouter" in done.stdout + done.stderr
@pytest.mark.unit
def test_the_installed_package_is_the_pinned_one():
# An editable install of a sibling checkout would make every test above
# judge code that `uvx` never delivers.
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
(line,) = [d for d in pyproject["project"]["dependencies"]
if d.startswith("invisible-playwright-mcp")]
pinned = re.fullmatch(r"invisible-playwright-mcp==(\S+)", line).group(1)
assert importlib.metadata.version("invisible-playwright-mcp") == pinned