Metadata-Version: 2.4
Name: postloop
Version: 0.1.0
Summary: Plan, check and learn from your community's social media posts.
Author: Winnie
License-Expression: MIT
Project-URL: Homepage, https://github.com/winniepopu/postloop
Project-URL: Source, https://github.com/winniepopu/postloop
Project-URL: Issues, https://github.com/winniepopu/postloop/issues
Keywords: community,social media,post calendar,automation,meta business suite,linkedin
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Topic :: Office/Business
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: tzdata; sys_platform == "win32"
Provides-Extra: xlsx
Requires-Dist: openpyxl>=3.1; extra == "xlsx"
Provides-Extra: xls
Requires-Dist: xlrd>=2.0; extra == "xls"
Provides-Extra: sheets
Requires-Dist: gspread>=6.0; extra == "sheets"
Provides-Extra: all
Requires-Dist: openpyxl>=3.1; extra == "all"
Requires-Dist: xlrd>=2.0; extra == "all"
Requires-Dist: gspread>=6.0; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: openpyxl>=3.1; extra == "dev"
Dynamic: license-file

# postloop

**Plan, check and learn from your community's social media posts.**

Community organizers plan social media posts in a spreadsheet, then post them by
hand. Afterwards nobody goes back to update the sheet: statuses stay at
"drafting", the "published at" column stays empty, and nobody notices the post
that went out twice or the one that never went out. And next year, nobody
remembers which posts worked. postloop does those boring parts, and closes the
loop: plan, post, learn, plan again.

| You have | Run | You get |
|---|---|---|
| a post calendar and your platform exports | `postloop --plan ... --posts ...` | what went out, what didn't, what the sheet got wrong |
| only your platform exports | `postloop report --posts ...` | what worked: top posts, ads vs organic, gaps |
| last year's exports and a new event date | `postloop bootstrap --posts ...` | a draft calendar for this year |

## Check the calendar

You need two things you already have:

1. **your post calendar**: any spreadsheet with a date and a title per post
2. **your platform exports**: the CSV/Excel files Meta Business Suite and
   LinkedIn let you download. No API keys, no data pipeline.

```bash
pip install "postloop[xlsx]"

postloop --plan calendar.xlsx --posts facebook.csv instagram.csv linkedin.xls
```

```
Calendar: 54 planned posts (date: 'Date', title: 'Post')
Posts:    facebook.csv → Meta Business Suite (Facebook), 45 posts
Posts:    instagram.csv → Meta Business Suite (Instagram), 38 posts

📅 Post calendar check
Matched 36 published posts
✍️ Cells that can be filled in: 70

🟡 Posted, but status not updated
• 09/02 Conference guide (Drafting → facebook 09-02 21:00)
🔴 Date passed, no post found
• 10/19 Sprints sponsor thanks
❓ Posts missing from the calendar
• 09/15 [facebook] Sponsor spotlight ...
♊ Possibly posted twice
• 09/13 20:31 [facebook] Only 2 days left for tickets!
```

It fills in **facts** (when each post went out, and its link) and never makes
**decisions**: a status that looks out of date is reported, not changed.

## Your calendar

Only a **date** and a **title** column are required. postloop finds columns by
name, in English or Chinese (`Date`/`日期`/`預計發布日`, `Title`/`Post`/`發文主題`
...), and skips notes above the header. Optional columns it understands:

| What | Recognized as |
|---|---|
| where it is posted | checkbox columns named `FB`, `IG`, `LinkedIn`, `X`, `Threads`... or one `Platforms` column like `FB, IG` |
| status | `Status` / `狀態` |
| published at | `Published at` / `實際發佈日期與時間` (postloop fills it in) |
| link | `URL` / `Link` / `網址` (postloop fills it in) |

No calendar yet? Start from [`templates/post-calendar.csv`](https://github.com/winniepopu/postloop/blob/main/templates/post-calendar.csv).
Different column names? `--date-col "When" --title-col "What"`.

## Your posts

| Platform | Where to download |
|---|---|
| Facebook | Meta Business Suite → Insights → Content → **Export data** → Facebook |
| Instagram | the same, choose Instagram |
| LinkedIn | your page → Analytics → Content → **Export** |
| anything else | a CSV with `platform, created_at, message, reactions` (+ `comments, shares, url`) |

The format is detected automatically. Meta allows 92 days per export; pass as
many files as you like, overlapping posts are counted once.

> **Gotcha we hit:** Meta writes publish times in **US Pacific time**, whatever
> your page's timezone. A post published at 10:10 in Taipei appears as 19:10 the
> evening before. postloop converts it; set your own zone with `--timezone`.

## Writing results back

```bash
postloop --plan calendar.xlsx --posts fb.csv --write          # saves calendar_filled.xlsx
postloop --plan calendar.xlsx --posts fb.csv --report out.csv # one line per post, all platforms
```

With a Google Sheet (`pip install "postloop[sheets]"`), `--sheet-id ID --write`
fills the live sheet, and `--discord` posts the summary to `$DISCORD_WEBHOOK`.
Run both every morning with GitHub Actions: see
[`examples/pycontw/daily-check.yml`](https://github.com/winniepopu/postloop/blob/main/examples/pycontw/daily-check.yml).

## No calendar yet?

Start from what you already posted:

```bash
postloop report --posts facebook.csv instagram.csv --timezone Asia/Seoul
```

For each platform: the typical post (median, so one viral post doesn't skew it),
the top posts by **organic** views (Meta exports say how many views came from
ads), boosted posts, post types, posts per month and the longest stretches with
no posts. It saves `post_report.md` and a CSV with every post.

## Plan next year from last year

A yearly event posts much the same things each year. Turn last year's posts into
a draft calendar:

```bash
postloop bootstrap --posts facebook.csv instagram.csv --timezone Asia/Seoul \
    --event-date 2026-10-17 --next-event 2027-10-16
```

Each post is placed the same number of days before the next event, on the same
weekday, with the year in its title updated and last year's numbers next to it.
A post that went out on several platforms becomes one row. The draft is a
starting point: if a deadline moved this year (early bird, proposals), move those
rows by hand.

## How matching works

Dates alone are unreliable: posts go out late, and several share a day. Each
calendar row and post pair gets a score from:

- **text similarity**: character pairs of the title found in the start of the
  post, weighted by rarity, so "PyCon" (in every post) counts little and a
  speaker's name counts a lot. Works for Chinese and English without a tokenizer.
- **sequence numbers**: "Part 2", "#2", "Day 2" and "第二天" must agree
- **lateness**: a small penalty per day late

Pairs are assigned best-first, one post per row. Low-confidence matches are
listed for a human to check.

Teach postloop your community's words in a config file (`--config postloop.toml`):

```toml
[synonyms]
shorts = "interview"   # the calendar says "shorts", the post says "interview"
```

On PyCon Taiwan's 2026 calendar it matched 32 of 36 Facebook posts with no
configuration, and 35 of 36 with no wrong matches once given ten synonyms.

## Examples

[`examples/pycontw/`](https://github.com/winniepopu/postloop/tree/main/examples/pycontw/) is how PyCon Taiwan runs it every
morning: their config, their GitHub Actions workflow, and how to feed postloop
from your own database instead of from exports.

## Development

```bash
pip install -e ".[dev]"
pytest
```

Releases are published to PyPI by `.github/workflows/publish.yml` when a GitHub
release is created.
