Open-Source Cold Email & Warmup Platform
Warmbly 是开源的冷邮件平台:温暖您的邮箱、发送活动、并对每条回复进行分流。可在我们的云端运行,也可自行托管整套系统。
AI 翻译
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证lurk finds the people on Reddit asking for what you sell. Point it at your URL, it infers the problem you solve and searches the words buyers use. Self-host it free; data is pay-per-request in USD through AnyAPI.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
lurk is a Reddit buyer-intent finder you can self-host for free, hosted at. It watches the subreddits and keywords your buyers use, scores every post
and comment against your product, tells you in one sentence why each one scored what it did,
and shows what the data behind that lead cost, down to the request.
Data comes from AnyAPI: one key, pay per request in USD, no
subscription. "Premium" here means connecting your own AnyAPI wallet, not paying us a monthly
fee. Self-hosting has no limits at all.查看完整 README ↗lurk is a Reddit buyer-intent finder you can self-host for free, hosted at https://lurk.so. It watches the subreddits and keywords your buyers use, scores every post and comment against your product, tells you in one sentence why each one scored what it did, and shows what the data behind that lead cost, down to the request.
Data comes from AnyAPI: one key, pay per request in USD, no subscription. "Premium" here means connecting your own AnyAPI wallet, not paying us a monthly fee. Self-hosting has no limits at all.
Deliberately absent, and not planned:
You need Docker, a free Clerk application for sign-in, an AnyAPI key and an OpenRouter key.
git clone <repo> lurk
cd lurk
cp .env.example .env
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" # APP_ENCRYPTION_KEY
Put that key in APP_ENCRYPTION_KEY, paste your Clerk, AnyAPI and OpenRouter keys into
.env, then:
docker compose up
The app applies its own migrations on start and serves on http://localhost:3000. Sign up, paste your product's URL, and press Scan now.
To let other people connect their own AnyAPI wallet instead of using your key, register this
instance as an OAuth client once and paste the printed id into ANYAPI_OAUTH_CLIENT_ID:
npm run anyapi:register
| Variable | Required | Default | What it does |
|---|---|---|---|
DATABASE_URL | yes | - | Postgres connection string. Compose sets it for you. |
APP_URL | yes | http://localhost:3000 | Public origin. Must match the registered OAuth redirect. |
APP_ENCRYPTION_KEY | yes | - | 32 random bytes, base64. Encrypts stored AnyAPI refresh tokens. |
SELF_HOSTED | no | false | true removes every tier limit. |
RUN_SCHEDULER | no | false | true on exactly one process runs the every-minute job tick. |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | yes | - | Clerk publishable key. |
CLERK_SECRET_KEY | yes | - | Clerk secret key. |
ANYAPI_BASE_URL | no | https://api.getanyapi.com | AnyAPI gateway. |
ANYAPI_OAUTH_CLIENT_ID | no | - | Printed by npm run anyapi:register. Needed for wallet connect. |
ANYAPI_HOUSE_API_KEY | no | - | The key used when a user has not connected a wallet. |
OPENROUTER_API_KEY | no | - | Pays for judging every title, post and comment the scan reads (TypeSafe's Jev), and for the product profile, competitor classification and clustering. Without it nothing is scored. |
OPENROUTER_MODEL | no | meta/muse-spark-1.3-contributor | Override the model. |
JEV_MODEL | no | ~typesafe/jev-latest | Override the judging model on OpenRouter. |
AI_GATEWAY_API_KEY | no | - | A Vercel AI Gateway key. When set, Jev is asked through the Gateway first and OpenRouter only answers when the Gateway fails. |
A variable set to nothing counts as unset, so an empty line in .env never half-configures a
feature.
A hosted instance has two tiers. Self-hosting is neither: it has no limits at all.
| Setting | Free | Connected wallet |
|---|---|---|
| Projects | 2 | unlimited |
| Keywords per project | 25 | unlimited |
| Tracked subreddits per project | 10 | unlimited |
| Scan cadence | daily, at an hour you pick | hourly |
| Comment scan | top 20 scored threads per scan | every thread over your threshold |
| Feed window | 30 days | 30 days |
| Alerts | daily digest, Slack and Discord, + 1 custom webhook | hourly, unlimited custom webhooks |
| Reddit SEO | 10 keywords, refreshed weekly | unlimited keywords, refreshed daily |
| Competitors | 3 | unlimited |
| Insights | full | full |
| API and MCP | read-only, 1,000 requests a day | read-only, unlimited requests |
Connecting a wallet buys freshness and breadth, not features. Every feature is on in every tier.
Sign in, open Settings, and press Connect AnyAPI wallet. You land on the AnyAPI consent
screen, where you set the spend cap this app may use, and come back connected. This app never
sees or stores an AnyAPI key: it holds a refresh token, encrypted with APP_ENCRYPTION_KEY,
and swaps it for a short-lived access token when it needs to make a call. Disconnect deletes
the token here and revokes it at AnyAPI.
Settings -> Alerts is where new leads land. Add an email digest, a Slack or Discord webhook,
or a generic webhook that receives the same digest as JSON. Slack and Discord cost nothing to
post to, so a project may add as many of those as it likes; the generic webhook is the one the
free tier caps, at one. Email needs ALERTS_FROM_EMAIL
and one carrier: AZURE_EMAIL_CONNECTION_STRING or SMTP_URL, in that order. On Azure, the local part of ALERTS_FROM_EMAIL must also be added as a sender
username on the domain, or every send is refused. The webhooks need nothing. A digest carries the day's new leads with
their score, reason, community and link, in the same shapes the feed uses, and sends nothing
at all when there is nothing new. The scheduler queues one digest pass an hour and each
channel decides whether its own cadence is due.
Slack takes a pasted webhook URL by default. To let people pick a channel instead, create a
Slack app at https://api.slack.com/apps with Incoming Webhooks on, the incoming-webhook
bot scope, and https://<your APP_URL>/connect/slack/callback as a redirect URL. Slack only
accepts https redirect URLs, so this does not work against http://localhost. Put the app's
client id and secret in SLACK_CLIENT_ID and SLACK_CLIENT_SECRET and the form shows an Add
to Slack button; the pasted-URL path stays one click away.
Settings -> API mints read-only keys prefixed rl_sk_. Send one as a bearer token:
curl -H "Authorization: Bearer $KEY" http://localhost:3000/api/v1/projects
Reading costs nothing, and every response says so with X-Request-Cost-Usd: 0. The schema is
served at /openapi.json and the agent guide, which explains what a
lead is and how to triage one, at /agent-guide.md. The same reads
are available to an agent over MCP at /api/mcp.
Real runs on this codebase against tally.so, on 2026-09-05. Data is what AnyAPI billed; model is what OpenRouter billed.
| Run | AnyAPI requests | Data | Model | What came back |
|---|---|---|---|---|
| First scan, 12 keywords and 5 subreddits | 27 | $0.028 | $0.0067 | 4 leads |
| Reddit SEO refresh, 3 keywords | 27 | $0.028 | - | 24 ranking threads |
| Competitor scan, 3 competitors | 27 | $0.032 | - | mentions with a sentiment each |
| Insights over stored leads | 0 | $0 | $0.0004 | pain themes and communities |
| Daily digest email | 0 | $0 | - | one email |
Two things keep the bill this small. Reddit rows are shared: the same search run inside your cadence window is reused, billed at zero, and the Data usage screen shows fetched against reused. And a scan is a funnel, so full posts and comment threads are only bought for the titles that survived triage.
Reddit posts and comments are public facts, so they are stored once and shared across projects: two people tracking the same keyword pay for one fetch between them. Scores are never shared; the same post can be a 92 for one product and a 12 for another. A shared Reddit post is deleted 30 days after it was posted, which is also the feed window, unless someone is still being shown it: a post behind a lead or a Reddit SEO thread is kept, with its comments, for as long as that lead or thread exists. Deleting the project, or the account, removes its leads, and the posts behind them go at the next daily cleanup.
Every Reddit and Google call this app makes goes through AnyAPI, using the published TypeScript SDK. That is the point of giving this away: the whole category sells you a subscription and hides what the data costs, and here the cost of a lead is printed on the lead.
Self-hosting needs none of this. It is how the one hosted instance runs.
scripts/azure-provision.sh creates everything in one resource group and is safe to run
again: every step checks for the resource first. Secrets are never arguments to it. They
live in deploy/env.production, which is gitignored, and the script generates the ones you
should not choose yourself.
mkdir -p deploy
cat > deploy/env.production <<'EOF'
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
ANYAPI_HOUSE_API_KEY=
OPENROUTER_API_KEY=
AI_GATEWAY_API_KEY=
ALERTS_FROM_EMAIL=
AZURE_EMAIL_CONNECTION_STRING=
SMTP_URL=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
EOF
RESOURCE_GROUP=reddit-leads-prod scripts/azure-provision.sh --dry-run
RESOURCE_GROUP=reddit-leads-prod scripts/azure-provision.sh
It writes PG_ADMIN_PASSWORD, APP_ENCRYPTION_KEY, DATABASE_URL and APP_URL back into
that file, and sets the same values as Container App secrets. Nothing else holds them.
What it creates, all in centralus by default and all on the smallest sensible tier:
| Resource | Tier |
|---|---|
| Container registry | Basic |
| Postgres flexible server, plus a database and a firewall rule for Azure services | Burstable Standard_B1ms, 32 GiB, version 17 |
| Container Apps environment | Consumption |
| Container app, one replica, port 3000, external ingress | 0.5 vCPU, 1 GiB |
User-assigned managed identity with AcrPull on the registry | - |
The app is pinned to exactly one replica because RUN_SCHEDULER=true must run on exactly
one process.
Set DOMAIN and the script prints the records to publish, adds the hostname, asks for a
managed certificate and binds it. If the records are not visible yet it says so and leaves
everything else provisioned, so it is safe to run again once DNS has propagated:
RESOURCE_GROUP=reddit-leads-prod DOMAIN=lurk.so scripts/azure-provision.sh
An apex domain such as lurk.so needs an A record to the environment's address, because a
CNAME cannot sit at the root of a zone; a subdomain gets a CNAME to the app instead. The
script picks the right shape and the matching certificate validation method for you.
Azure resolves those records itself to prove you own the name and to issue the certificate, so they must be served as published. Behind a proxying CDN, Cloudflare's orange cloud included, Azure sees the proxy's address and its certificate instead and validation never passes. Set the records to DNS-only until the certificate is issued.
.github/workflows/deploy.yml signs in with OpenID Connect, so no Azure password is stored
on GitHub. scripts/azure-github-oidc.sh creates the app registration, the federated
credential that trusts one repository and branch, and the two role assignments the workflow
needs:
GITHUB_REPO=owner/repo RESOURCE_GROUP=reddit-leads-prod scripts/azure-github-oidc.sh --dry-run
GITHUB_REPO=owner/repo RESOURCE_GROUP=reddit-leads-prod scripts/azure-github-oidc.sh
Then set these on the repository, with gh variable set and gh secret set or in the
repository settings:
| Repository variable | Where it comes from |
|---|---|
AZURE_RESOURCE_GROUP | the resource group you provisioned |
AZURE_CONTAINER_REGISTRY | registry name, printed by the provision script |
AZURE_CONTAINER_APP | container app name, printed by the provision script |
APP_HEALTH_URL | https://<hostname>/api/health, printed by the provision script |
| Repository secret | Where it comes from |
|---|---|
AZURE_CLIENT_ID | printed by scripts/azure-github-oidc.sh |
AZURE_TENANT_ID | printed by scripts/azure-github-oidc.sh |
AZURE_SUBSCRIPTION_ID | printed by scripts/azure-github-oidc.sh |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY | openssl rand -base64 32, once; never rotate it casually |
The encryption key salts every Server Action id. Built without it, each deploy gets a new random key, every id changes, and any tab open across the deploy fails on its next click. The deploy refuses to build without it.
A deploy builds the image, pushes it, rolls the container app to a revision named after the
commit, and then polls APP_HEALTH_URL until it answers 200. Migrations are not a separate
job: docker-entrypoint.sh applies them before the server starts, and one replica means
they run once.
scripts/deploy-manual.sh does the same from a clean checkout, for when the workflow is
not an option:
RESOURCE_GROUP=reddit-leads-prod scripts/deploy-manual.sh
It pins --platform linux/amd64, because an image built on an Apple Silicon Mac without it
pushes fine and then fails to pull with "not found".
npm install
docker compose up -d postgres
cp .env.example .env # fill in the keys, point DATABASE_URL at localhost:5433
npm run db:migrate
npm run dev
npm run check runs the typecheck, the linter and the unit tests. The database-backed tests
skip unless DATABASE_URL is set, and never write to that database: they use a sibling named
<database>_test on the same server (or TEST_DATABASE_URL), created and migrated on the
first run. They also drop every paid key from the environment, so a test cannot spend.
npm run db:generate writes a new migration after a schema change.
MIT.
JEV_GATEWAY_MODEL | no | typesafe-ai/jev | Override the judging model on the Gateway. |
ALERTS_FROM_EMAIL | no | - | The From address on a digest. Email needs this and one of the two below. |
AZURE_EMAIL_CONNECTION_STRING | no | - | Sends the digest through Azure Communication Services. Wins when both are set. |
SMTP_URL | no | - | Sends the digest through any SMTP server, as smtps://user:pass@host:465. |
ALERT_INVITES | no | false | true emails each person who has leads and no alert channel, once, asking whether they want new leads by email. Needs email set up as above. |
SLACK_CLIENT_ID | no | - | With the secret, turns the Slack channel into an Add to Slack button. |
SLACK_CLIENT_SECRET | no | - | The other half of the Slack app. |
DISCORD_CLIENT_ID | no | - | With the secret, turns the Discord channel into an Add to Discord button. |
DISCORD_CLIENT_SECRET | no | - | The other half of the Discord app. |
HOUSE_DATA_CAP_USD_PER_DAY | no | 50 | Daily ceiling on data spend from the house key. |
HOUSE_LLM_CAP_USD_PER_DAY | no | 100 (10 under the Compose file) | Daily ceiling on language model spend. |
X_LEADS | no | false | Turns on the X leads tab and its jobs (beta, off by default). |
X_REPLIES | no | true | Shows X posts worth a reply beside the asks. false turns off only that kind. |
HOUSE_X_DATA_CAP_USD_PER_DAY | no | 5 | X's share of the house data ceiling. |
HOUSE_X_LLM_CAP_USD_PER_DAY | no | 5 | X's share of the house language model ceiling. |
X_CALLS_IN_FLIGHT | no | 8 | Paid X calls in flight at once. |
ALERTS_ALLOW_PRIVATE_WEBHOOKS | no | false | Webhooks may only reach public addresses. Set true on a self-hosted instance to deliver to your own network. |