Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Contributing

Thanks for helping. There are two kinds of contribution: code changes and hashtag list changes. The second matters most, because the list is the product.

## Code changes

1. Set up the repo as described in the [README](README.md#local-setup).
2. Keep changes small and focused. Use [Conventional Commits](https://www.conventionalcommits.org) messages, for example `feat(api): …`, `fix(web): …`, `docs: …`.
3. Before opening a pull request, run:

```sh
pnpm lint && pnpm typecheck && pnpm test
```

CI runs the same checks plus `pnpm build`.

4. Add or update tests with your change. TypeScript is strict; avoid `any`.
5. In `apps/web`, code doubles as learning material, so short comments that explain the React idea behind a piece of code are welcome.

Hosting must stay free. Do not add paid Cloudflare products, KV-based caching or a custom domain (see `docs/PLAN.md`).

## Proposing hashtag list changes

The list lives in [`apps/api/seed/hashtags.json`](apps/api/seed/hashtags.json); its fields and provenance rules are in [`apps/api/seed/README.md`](apps/api/seed/README.md). Open a pull request that edits the JSON and include the evidence in the description.

What counts as evidence:

| Proposed status | Evidence we need |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `banned` | A first-hand check from the last 30 days: a Graph API `ig_hashtag_search` error for the tag, or screenshots showing the tag page hidden while logged in. Include the date and method. |
| `restricted` | A first-hand check showing the tag is limited (for example "recent posts" hidden or a sensitive-content notice), with date and method. |
| `clear` | A first-hand check showing the tag works normally, with date and method. |
| `reported` | A link to where the tag is reported as banned. No verification needed, but it stays `reported`. |

Rules:

- Use the normalized form of the tag: no `#`, lowercase, the same rules as `parseHashtags` in `packages/shared`.
- Set `source` to how the claim was made (for example `graph-api`, `manual-check`, or the name of a published list), put a short summary of the evidence in `notes`, and set `last_verified_at` to the ISO date of the check. Leave it `null` for `reported`.
- Do not use Instagram's `/popular/<tag>/` page as evidence. It was tested on 2026-10-10 and does not track bans (see the seed README).
- Keep the file sorted by `tag`.
- Do not include personal data, such as account names or private screenshots, in the pull request.

To remove a tag or mark a banned tag as `clear` again, follow the same process: show a recent first-hand check.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2018-2026 Dominic Idagu

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.
97 changes: 96 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,98 @@
# CleanHashtags

CleanHashtags checks Instagram hashtags for bans and restrictions. The original 2018 PHP and jQuery app is gone because the Instagram page it scraped no longer exposes the signal it relied on. The app is being rebuilt as a TypeScript app on Cloudflare with a React frontend; see [docs/PLAN.md](docs/PLAN.md) for the approach and the step-by-step plan.
CleanHashtags tells you which of your Instagram hashtags are known to be banned or restricted, so you can drop them before you post. Paste up to 30 hashtags and get a verdict for each one, with where that verdict comes from and how old it is.

It runs on Cloudflare's free Workers plan: one Worker serves the React app and a small JSON API backed by a D1 database.

## How verdicts work

Instagram has no public "is this hashtag banned?" endpoint, and the page the original 2018 version scraped no longer shows the signal. CleanHashtags therefore looks tags up in a **curated list**, and every entry carries its evidence:

| Status | Meaning |
| ------------ | --------------------------------------------------------------------------- |
| `banned` | Verified first-hand: Instagram hides the tag. |
| `restricted` | Verified first-hand: the tag works but Instagram limits it. |
| `reported` | Listed by others (for example old "banned hashtag" lists) but not verified. |
| `clear` | Verified first-hand as usable. |
| `unknown` | Not in the list. This is **not** a "clear"; it means we have no data on it. |

Each result shows its `source` and, when the tag was verified, how many days ago. "Copy safe hashtags" copies only `clear` and `unknown` tags.

Limits worth knowing:

- The list is small. Today every entry is `reported`, taken from 2019–2020 community lists, and none has been verified by this project.
- Instagram changes its rules without notice, so even a verified verdict goes stale.
- A live check through the Instagram Graph API (`ig_hashtag_search`) is possible but limited to 30 unique hashtags per account per 7 days and needs Meta app review. Only a stub exists (`apps/api/src/verifier`), and it is off by default.

The reasoning behind this approach, including the 2026 calibration that ruled out scraping, is in [docs/adr/0001-stack-and-data-source.md](docs/adr/0001-stack-and-data-source.md).

## Local setup

Requirements: Node 22 (see `.nvmrc`) and pnpm (the version is pinned in `package.json`; `corepack enable` picks it up). No Cloudflare account is needed for local development.

```sh
pnpm i
pnpm --filter api db:migrate:local # create the local D1 schema
pnpm --filter api db:seed # load seed/hashtags.json (re-runs migrations; safe to repeat)
pnpm dev # API on :8787, web app on Vite's port (usually :5173)
```

Open the Vite URL; it proxies `/api` to the Worker. Other scripts, all from the repo root:

| Command | What it does |
| ---------------- | -------------------------------------------------- |
| `pnpm lint` | ESLint and Prettier check |
| `pnpm typecheck` | `tsc --noEmit` in every package |
| `pnpm test` | Vitest in every package (API tests run in workerd) |
| `pnpm build` | Builds the web app to `apps/web/dist` |
| `pnpm format` | Prettier write |

## Deploy

Everything below fits Cloudflare's free plan on the `*.workers.dev` subdomain. Do not add a custom domain or paid products.

```sh
cd apps/api
pnpm exec wrangler login
pnpm exec wrangler d1 create cleanhashtags
```

Copy the printed `database_id` into the `d1_databases` entry in `apps/api/wrangler.jsonc`, then:

```sh
pnpm exec wrangler d1 migrations apply cleanhashtags --remote
pnpm exec node scripts/seed.ts --remote # pnpm exec puts wrangler on PATH
pnpm --filter web build # the Worker serves apps/web/dist
pnpm exec wrangler deploy # publishes to https://cleanhashtags.<your-subdomain>.workers.dev
```

`ALLOWED_ORIGINS` in `wrangler.jsonc` can stay empty because the app and API share an origin. Keep `GRAPH_API_ENABLED` at `"false"`.

## Project layout

```
apps/api Cloudflare Worker (Hono): /api routes, D1 access, rate limiting, verifier stub
migrations/ D1 schema
seed/ starter hashtag list and its provenance
apps/web React + Vite frontend, served by the Worker as static assets
packages/shared hashtag parsing and Zod schemas used by both apps
docs/ PLAN.md, ADRs and React learning notes
```

## Learning React with this repo

The frontend was built step by step as a React learning project. Each step has a note explaining the concepts it introduced, where to find them in the code, and exercises to try:

1. [Components and JSX](docs/learning/01-components-and-jsx.md)
2. [State and controlled inputs](docs/learning/02-state-and-controlled-inputs.md)
3. [Events, async and effects](docs/learning/03-events-async-and-effects.md)
4. [Lists, keys and lifting state](docs/learning/04-lists-keys-and-lifting-state.md)
5. [Custom hooks and styling](docs/learning/05-custom-hooks-and-styling.md)

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md), including how to propose changes to the hashtag list.

## License

[MIT](LICENSE)
4 changes: 4 additions & 0 deletions apps/api/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copy to .dev.vars for local `wrangler dev`. In production use `wrangler secret put <NAME>`.
# Only needed once the Graph API verifier is implemented and GRAPH_API_ENABLED is "true".
GRAPH_API_ACCESS_TOKEN=
GRAPH_API_IG_USER_ID=
58 changes: 58 additions & 0 deletions apps/api/src/verifier/graph-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Verifier, VerifiedResult } from "./verifier";

/**
* Instagram Graph API limit: each Business/Creator account may query at most
* 30 unique hashtags in a rolling 7-day window. Re-querying a tag already in
* the window does not count again.
*/
export const GRAPH_API_HASHTAG_BUDGET = { uniqueTags: 30, windowDays: 7 };

export interface GraphApiConfig {
/** Long-lived user access token of the person who connected their account. */
accessToken: string;
/** Instagram Business or Creator account id (not the Facebook Page id). */
igUserId: string;
}

export class GraphApiNotImplementedError extends Error {
constructor() {
super(
"GraphApiVerifier is a stub; live Graph API calls are not implemented.",
);
this.name = "GraphApiNotImplementedError";
}
}

/**
* Skeleton for live verification through the Instagram Graph API. It makes no
* network calls yet.
*
* Intended flow, per tag:
* GET https://graph.facebook.com/<version>/ig_hashtag_search
* ?user_id=<igUserId>&q=<tag>&access_token=<accessToken>
* - `{ data: [{ id }] }`: Instagram exposes the tag, so it is `clear`.
* - A generic error for an otherwise valid tag: Instagram hides it, so it is
* `restricted`. The API does not say whether a tag is banned or only
* restricted, and the error is not documented as a ban signal, so these
* results should be reviewed before they overwrite curated entries.
* - Auth, permission and rate-limit errors are not verdicts: skip the tag.
*
* Constraints:
* - Budget: see GRAPH_API_HASHTAG_BUDGET. Track queried tags per account and
* stop before the budget is spent; prefer tags missing from the list.
* - Account: an Instagram Business or Creator account linked to a Facebook Page.
* - Permissions: `instagram_basic` and Page access (`pages_show_list`,
* `pages_read_engagement`), plus the "Instagram Public Content Access"
* feature, which needs Meta app review. Check Meta's current docs before
* building this; names and versions change.
* - The token is a secret (`wrangler secret put`), never a plain var.
*/
export class GraphApiVerifier implements Verifier {
readonly name = "graph-api";

constructor(readonly config: GraphApiConfig) {}

verify(_tags: readonly string[]): Promise<VerifiedResult[]> {
return Promise.reject(new GraphApiNotImplementedError());
}
}
40 changes: 40 additions & 0 deletions apps/api/src/verifier/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { GraphApiVerifier } from "./graph-api";
import { NoopVerifier, type Verifier } from "./verifier";

export {
GRAPH_API_HASHTAG_BUDGET,
GraphApiNotImplementedError,
GraphApiVerifier,
type GraphApiConfig,
} from "./graph-api";
export {
NoopVerifier,
type VerifiedResult,
type VerifiedStatus,
type Verifier,
} from "./verifier";

/** The subset of Worker env the verifier reads. Credentials are secrets, so they are optional here. */
export interface VerifierEnv {
GRAPH_API_ENABLED?: string;
GRAPH_API_ACCESS_TOKEN?: string;
GRAPH_API_IG_USER_ID?: string;
}

export function isGraphApiEnabled(env: VerifierEnv): boolean {
return env.GRAPH_API_ENABLED?.trim().toLowerCase() === "true";
}

/** Returns the NoopVerifier unless GRAPH_API_ENABLED is "true". */
export function createVerifier(env: VerifierEnv): Verifier {
if (!isGraphApiEnabled(env)) return new NoopVerifier();

const accessToken = env.GRAPH_API_ACCESS_TOKEN;
const igUserId = env.GRAPH_API_IG_USER_ID;
if (!accessToken || !igUserId) {
throw new Error(
"GRAPH_API_ENABLED is set but GRAPH_API_ACCESS_TOKEN or GRAPH_API_IG_USER_ID is missing.",
);
}
return new GraphApiVerifier({ accessToken, igUserId });
}
30 changes: 30 additions & 0 deletions apps/api/src/verifier/verifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { HashtagStatus } from "@cleanhashtags/shared";

/** A live check can only tell "searchable" from "hidden"; it never yields `banned` vs `restricted` nuance. */
export type VerifiedStatus = Extract<HashtagStatus, "restricted" | "clear">;

export interface VerifiedResult {
hashtag: string;
status: VerifiedStatus;
/** Identifies the verifier, e.g. `graph-api`. Stored as the list's `source`. */
source: string;
verifiedAt: string;
}

/**
* Live verification of hashtags against an external signal. Results are meant
* to feed back into the curated list. Implementations return results only for
* tags they actually verified; tags left out stay as the list has them.
*/
export interface Verifier {
readonly name: string;
verify(tags: readonly string[]): Promise<VerifiedResult[]>;
}

export class NoopVerifier implements Verifier {
readonly name = "noop";

verify(_tags: readonly string[]): Promise<VerifiedResult[]> {
return Promise.resolve([]);
}
}
79 changes: 79 additions & 0 deletions apps/api/test/verifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { env } from "cloudflare:workers";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createVerifier,
GraphApiNotImplementedError,
GraphApiVerifier,
isGraphApiEnabled,
NoopVerifier,
} from "../src/verifier";

afterEach(() => {
vi.restoreAllMocks();
});

describe("NoopVerifier", () => {
it("verifies nothing and makes no network calls", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");

await expect(
new NoopVerifier().verify(["sunset", "alone"]),
).resolves.toEqual([]);
expect(fetchSpy).not.toHaveBeenCalled();
});

it("handles an empty tag list", async () => {
await expect(new NoopVerifier().verify([])).resolves.toEqual([]);
});
});

describe("createVerifier", () => {
it("defaults to the noop verifier with the Worker's configured env", () => {
expect(isGraphApiEnabled(env)).toBe(false);
expect(createVerifier(env)).toBeInstanceOf(NoopVerifier);
});

it.each([undefined, "", "false", "0", "yes"])(
"stays noop when GRAPH_API_ENABLED is %j",
(flag) => {
const verifier = createVerifier({
GRAPH_API_ENABLED: flag,
GRAPH_API_ACCESS_TOKEN: "token",
GRAPH_API_IG_USER_ID: "1784",
});

expect(verifier.name).toBe("noop");
},
);

it("builds the Graph API verifier when enabled with credentials", () => {
const verifier = createVerifier({
GRAPH_API_ENABLED: " TRUE ",
GRAPH_API_ACCESS_TOKEN: "token",
GRAPH_API_IG_USER_ID: "1784",
});

expect(verifier).toBeInstanceOf(GraphApiVerifier);
});

it("refuses to enable the Graph API without credentials", () => {
expect(() => createVerifier({ GRAPH_API_ENABLED: "true" })).toThrow(
/GRAPH_API_ACCESS_TOKEN/,
);
});
});

describe("GraphApiVerifier stub", () => {
it("rejects without calling the network", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const verifier = new GraphApiVerifier({
accessToken: "token",
igUserId: "1784",
});

await expect(verifier.verify(["sunset"])).rejects.toBeInstanceOf(
GraphApiNotImplementedError,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
4 changes: 4 additions & 0 deletions apps/api/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,9 @@
// Comma-separated origins allowed to call the API cross-origin. The web app is
// served by this same Worker, so production needs none.
"ALLOWED_ORIGINS": "",
// Live verification through the Instagram Graph API (src/verifier). Stub only:
// keep "false". Enabling it also needs the GRAPH_API_ACCESS_TOKEN and
// GRAPH_API_IG_USER_ID secrets (see .dev.vars.example).
"GRAPH_API_ENABLED": "false",
},
}
Loading