Skip to content

Releases: HaoweiChan/tinboker

v0.11.11 — listen at the source

Choose a tag to compare

@HaoweiChan HaoweiChan released this 02 Sep 17:54
a4f7875

The episode audio was never ours. This release stops serving it.

Listening moves to the source (#570)

AdSense suspended the site for "Google-served ads on screens with replicated content". The literal case was ours: the complete episode MP3, re-hosted on our own domain, with ads around it. That is copyright exposure independent of any ad policy.

The in-house player is gone wherever a source link exists, and Spotify is now styled as the listen action rather than a secondary link:

最近 100 集:spotify_url 96 (96%) / 缺 4 (4%)

Up from 27% on 2026-08-27 — the backfill chain (#561#563, #565) closed that gap. The remaining 4% keep the player, so no episode loses the ability to play.

getEpisodeAudioUrl now takes spotifyUrl as a required argument. Every play surface routes through it, so a new caller cannot quietly reintroduce the hosted player — that constraint is the part worth keeping.

The files are still stored and still served for that 4%. This removes the reader-facing path, not 60 GB of MP3s; deleting those is a separate, irreversible decision.

Names the ASR got wrong (#566)

Readers reported this on Threads — 「是 warsh」, 「每一篇都有不少錯字」. The transcriber mishears proper nouns (欣興→新興, 績優生→機油生) and each branch of the pipeline inherited whatever it was handed, so a single episode shipped both spellings: the summary corrected, the slides and social cards not.

One correction pass now runs at the join and applies a single map to every artifact. Proposals must be equal-length, same-script, and already present in the episode — the transcript itself is never rewritten, since it is the record of what was actually heard.

A known limit, stated plainly: the backfill path has a human gate, and its first dry run proposed 升息 → 降息 — a rate call inverted, not a homophone. That proposal passes every code-side guard. The live path has no such gate; its only defence against that class is the prompt. The cap is 12 corrections per episode, so the blast radius is bounded — but bounded is not guarded.

Extractor headroom (#571)

The first production ingest after the v0.11.10 model pin logged two completion_tokens=8192 … length limit was reached — a ceiling never hit before. flash-0731 segments 2–3× more finely than v4-pro, so the JSON got longer. Raised to 16384; on flash a full reply costs US$0.0016.

Episode failure rates were unchanged either side of the pin (2/15 vs 4/32), so this removes an avoidable source of truncation rather than fixing a regression.

Also: media paths resolve without GCS credentials (#572), card repair refuses to run when the translations table is unreachable (#573), decks and social-card PNGs rebuilt (#574), and two dead show links fixed (#568 #569).

Verified on staging

staging-api /health      200, 1.17s
staging /sitemap.xml     application/xml — 493 URLs, /topics/:tag 0, sector 80

The SEO work from v0.11.10 is intact. The player change could not be checked visually on staging — it is behind Google OAuth and the bypass token is a rotating secret that should not end up in a URL in a transcript. That check happens on production, which is not gated.

Where this leaves AdSense

Both findings now have an answer: the audio is no longer served to readers, and the thin topic pages left the index in v0.11.10. Requesting a review is still premature — the "low value content" half rests on original writing that does not exist yet (/api/articles returns []).

Full changelog: v0.11.10...v0.11.11

v0.11.10 — cost and crawl budget

Choose a tag to compare

@HaoweiChan HaoweiChan released this 01 Sep 18:10
4bf3bf1

Nothing a visitor will notice. This release cuts what the site costs to run and stops spending Google's attention on pages that were never worth indexing.

The extractor was 84% of the LLM bill (#564)

extractor is the only pipeline role handed {sentences} — the raw transcript. Every other role works on what it already produced ({events}, {markdown}, {overview}), one to two orders of magnitude smaller. So one role out of seven accounted for 175M prompt tokens in 30 days, ~US$174/month.

The 2026-06 model bake-off that put every role on deepseek-v4-pro judged tag vocabulary and ticker linking — the writer's and ticker_extractor's jobs, on {events}. The role burning the tokens was never the one that decision was about.

Pinned to deepseek-v4-flash-0731, measured across four episodes (537 / 800 / 1102 / 2732 sentences):

v4-pro flash-0731
sentence coverage 100% 100%
out-of-range index ranges 0 0
off-vocabulary segment_type 0 0
chapters after consolidation 4 / 8 / 12 4 / 8 / same
cost per episode 93–98% lower

It emits more raw events, and that turns out not to matter: consolidate_chapters scales chapter count by episode duration, capped at 12, so how finely the extractor split is normalised downstream. That was the open question, and answering it is why this is a safe swap rather than just a cheap one. The other six roles stay on v4-pro.

Two candidates were rejected on correctness rather than price — qwen3.7-flash left 56 sentences uncovered, ling-3.0-flash cut 800 sentences into 100 events.

The thin topic pages (#560)

/topics/:tag renders 97 characters of its own — nav chrome plus one sentence identical across all ~166 pages — wrapped around a list of links to episode summaries. Those pages now carry X-Robots-Tag: noindex and are gone from the sitemap, which drops from 657 URLs to 488.

They still work; they just stop being something Google evaluates. /topics itself and the 82 sector pages are deliberately kept: the sector pages carry a hand-written thesis and per-constituent descriptions, and /topics hosts the discussion-heat backtest. Those are curation, not filler.

Spotify backfill (#561 #562 #563 #565)

The episode lookup could not see past the newest 100 episodes, ran against the wrong store, paged once per episode instead of once per show (~27,000 requests down to ~240), and could not tell a 429 from a genuine miss. All four fixed; 429 now honours Retry-After and says so.

Verified on staging

$ curl -s https://staging.tinboker.com/sitemap.xml | grep -c '<url>'
488                      # was 657; /topics/:tag went 166 → 0
sector 80 · topics 1 · episode 291 · stock 101 · podcaster 10
$ staging-api /health    200, 0.94s warm

The per-route noindex cannot be verified anywhere but production — every non-prod host already returns the header for a different reason (v0.11.9 stamps dev/staging wholesale). Same for the extractor pin: it lives in a systemd unit only the production deploy installs.

After this deploys

  • Watch one ingest slot (02/08/14/20:10 TW) and confirm the episodes have real chapters, not placeholder summaries. That is the first real exercise of the new extractor model.
  • /topics/ai should return noindex on tinboker.com; /topics should not.
  • Warm /sitemap.xml once — it regenerates cold after a container restart (45s last time, ~1.4s warm).

Full changelog: v0.11.9...v0.11.10

v0.11.9 — search indexing coverage

Choose a tag to compare

@HaoweiChan HaoweiChan released this 27 Aug 02:21
0f8996d

Nothing here changes what the site looks like. The release is about being findable: roughly 250 pages that Google had no way to index, or no way to tell apart, now have their own titles and are enumerated in the sitemap.

Search indexing (#548)

The crawler-meta edge function covered only /episode, /article and /stock. Everything else served index.html's generic title to Googlebot verbatim, which is a reliable way not to get indexed.

Fixed Pages
/topics and /podcaster served the homepage's title and description to crawlers 176
/sector/:id was in neither the sitemap nor the middleware — undiscoverable 81
https://tinboker.com/sitemap.xml answered with index.html (200 text/html) all

Each sector page now carries its own zh-TW description — the paragraph the page already renders — rather than a template. /tag/:tag and /topics/:tag render the same component and now both canonicalize to /topics/, so the page no longer competes with itself. dev. and staging. were fully indexable copies of production and now return X-Robots-Tag: noindex.

functions/ sits outside src/, so neither tsc nor the changed-files lint ever saw that file. npm run validate:seo now covers it in CI.

Also in this release

  • Threads comment triage (#553) — an admin 留言 tab that pulls comments, classifies them, and drafts replies. The unattended auto-reply loop is deliberately held off in this release (#556): it is production-only by design, so no other environment can exercise it first, and it should be switched on by a person watching the triage rather than by a deploy. Manual sync and reply work now.
  • Audio cache headers (#551) — immutable caching for episode media.
  • VPS container-runtime CPU leak (#547).

Verified on staging before tagging

staging /topics/ai            <title>#AI | 聽播客 TinBoker</title>
staging /sector/sector_mlcc   <title>被動元件 MLCC | 聽播客 TinBoker</title>
staging /podcaster/Gooaye…    <title>Gooaye 股癌 · Podcast 頻道 | 聽播客 TinBoker</title>
staging /                     x-robots-tag: noindex, nofollow
staging /sitemap.xml          application/xml — 659 URLs, 82 /sector/

Manual follow-ups

Resubmit the sitemap at https://tinboker.com/sitemap.xml in Search Console, and request removal of any dev./staging. URLs already indexed — the new noindex only prevents future indexing. Recrawling ~570 pages takes weeks, so traffic will not move immediately.

Full changelog: v0.11.8...v0.11.9

v0.11.8

Choose a tag to compare

@HaoweiChan HaoweiChan released this 24 Aug 17:48
434f734

Internal release — admin and pipeline only. Nothing user-facing changed, so there is no in-app "What's new" entry (per docs/workflows/deploy-flow.md: a release that is all plumbing gets none).

Per-show publish switch (#543)

content_sources.social_enabled — an operator toggle in the admin 內容來源 table, independent of active. When off, that show's episodes are never published anywhere, while the show keeps being ingested and keeps feeding the site.

Enforced at every outbound path:

  • Threads and Facebook publishers, batch scan and single-episode admin publish
  • the three syndication endpoints (方格子, Substack, and the combined one)
  • the pipeline, which skips the social-copy LLM call and the Marp card render outright, so a muted show costs nothing rather than generating output that is later refused

Every check fails open: an unreachable platform or a DB blip keeps publishing rather than silently muting every show.

Live on production with 財經一路發 and M觀點 muted.

Pipeline reliability

  • #545 — ingest TimeoutStartSec 2h → 5h. systemd had been SIGTERMing healthy runs mid-episode: runs straddle the old ceiling (one finished cleanly at 1h20, the next hit the wall at 2h). The ceiling still guards against a wedged run holding the timer; it now sits above the real envelope and below the timer's 6h cadence.
  • #546 — a 404 on episode audio is no longer retried three times. Two back-catalogue episodes whose source audio was deleted had been burning three requests each on every tick. 408 and 429 stay on the retry path.

Verification

  • Both prod workflows green; api.tinboker.com/health healthy on the first poll
  • tinboker-backend-prod on ghcr.io/haoweichan/tinboker-backend:v0.11.8, RestartCount=0
  • Smoke suite 10/10 against api.tinboker.com + tinboker.com
  • Two consecutive ingest ticks now reach Deactivated successfully (1h45 and 1h34) instead of Failed with result 'timeout'

v0.11.7 — AdSense revenue panel, and four holes closed in the running system

Choose a tag to compare

@HaoweiChan HaoweiChan released this 21 Aug 06:48
1ce18ed

Patch release. Adds the monetization half of the admin analytics page, and closes four exposures found by auditing the running VPS rather than by reading code. Nothing user-visible changes — that is why there is no what's-new entry this time.

Security

marp-flask-service was on the public internet (#536)

The compose file published 5004:5004, which binds 0.0.0.0. Docker inserts its publish rules ahead of ufw, so the host's default deny (incoming) never applied — unlike every other container here, all of which bind 127.0.0.1. Verified from outside the host, not inferred: an unauthenticated POST /convert returned a rendered PPTX.

That endpoint and /render-png feed arbitrary markdown to marp, which renders it with headless Chromium and --allow-local-files; /upload writes arbitrary request bodies to disk. There is no auth, no TLS and no rate limit, and each request spawns a Chromium with a 30–120s timeout. Now bound to loopback — the only caller is social_cards_render.py on the same host, whose MARP_SERVICE_URL already defaults to http://localhost:5004.

API docs and schema no longer served in production (#537)

/docs and /openapi.json were public on both api.tinboker.com and podcast-api.tinboker.com — a complete map of every endpoint and parameter, including the key-gated write routes on the pipelines app. The backend gates them on settings.is_production, so dev and staging keep theirs; podcast-api is blocked in Caddy, since that app is a single hand-deployed instance with no env split of its own.

podcast-api read endpoints are no longer world-readable (#539, #540)

1,441 enriched news articles, episode claims and ticker insights were free to scrape. Three months of access log confirmed no external consumer exists before anything was locked: the frontend never references that host, the main backend does not proxy it, and 333 of 431 /api requests were 404s from scanners probing /api/.env and friends.

/api/* now answers only to localhost and private ranges. /media/* stays public — those are article covers served to real browsers, Googlebot-Image, facebookexternalhit and the cover pipeline.

Worth recording: #539 first gated this on the presence of X-API-Key, assuming the app validated the value. Only the write routes do — curl -H 'X-API-Key: anything' passed straight through, and since this repo is public that commit's own comment published the bypass. #540 replaces it with the network-scope gate, which needs no shared secret and fails closed.

AdSense crawler let through the meta middleware (#538)

mediapartners added to the crawler pattern in the Cloudflare Pages middleware.

Admin

AdSense revenue panel (#535)

A new card on the admin analytics page: estimated earnings, page RPM, ad-request coverage (fill rate) and Active-View viewability, plus a per-day series and the top earning pages. Coverage and viewability are the actionable pair — coverage drops mean ad requests are not being filled, low viewability means Auto ads landed below the fold.

AdSense has no service-account support, so this reads a stored authorized-user OAuth credential (ADSENSE_OAUTH_JSON) and falls back to local ADC on a dev box; every failure path returns configured/available flags instead of raising, matching CloudflareAnalyticsService.

tinboker.com is still GETTING_READY in AdSense review, so the card shows an explicit review banner rather than a bare row of zeros that would read as broken serving.

Also in this release

3c694ff (NaN-safe sector performance sums) reached develop via the back-merge in #541. It is the fix that stopped the /api/sectors/performance 500s in production — last one at 08-19 18:04 UTC, none since.

Full changelog: v0.11.6...v0.11.7

v0.11.5 — podcaster icons and episode media restored

Choose a tag to compare

@HaoweiChan HaoweiChan released this 19 Aug 13:30

Patch release. The headline fix restores podcast cover art and episode media across the site; the rest is the social-autopublish pipeline reaching a working end-to-end run.

User-facing

Podcaster icons and episode media are back (#529)

tempfile.mkstemp creates files at 0600 and os.replace preserves that mode, so every artifact written into the media store was unreadable by Caddy, which runs as a different user. The result was a 403 on the entire media tree — podcast cover art, episode mp3s, transcripts and summaries alike. Both writers (backend/src/services/gcs_content.py and pipelines/services/podcast/src/service/gcs_storage_service.py) now chmod 0644 before the atomic rename.

The same incident had a second face: every avatar was a bare <img> whose PodMark fallback only fired when the URL was absent, never when it failed to load. With alt="" that renders as an empty box, which is why subscribed channels appeared to have no icons at all. A new PodAvatar component falls back on onError, and all six avatar call sites route through it — so a dead media URL now degrades to the initial mark instead of a hole.

Existing files on the VPS were repaired with a one-time chmod; this release stops new writes from recurring the problem.

Pipelines — social autopublish end-to-end

The Threads / vocus / Substack autopublish path now completes a full scheduled run. Fixes in this release:

  • Threads copy is carried through to the episode and its trigger result is read from the platforms envelope (#523, #528)
  • Threads copy voice rewritten with its own pinned model (#520)
  • Syndicate step reads the summary key the pipeline actually writes; the endpoint accepts publish_substack instead of dropping it (#524, #525)
  • Deterministic row pick when one title exists under two episode ids (#525)
  • Admin calls go to staging's direct origin rather than through Cloudflare (#528)
  • Ingest: every show gets a usable episode limit, runs that did nothing no longer report success, psycopg gets a URL it can parse, and a recovery path when an endpoint refuses to disable reasoning (#518, #519, #521, #522)

Full changelog: v0.11.4...v0.11.5

v0.10.0 — AdSense 變現、方格子發文、社群同步

Choose a tag to compare

@HaoweiChan HaoweiChan released this 12 Aug 07:36
2c74a1e

What's Changed

  • feat(shared): add entities.yaml for tinboker<->Hermes ticker/name alias sync by @HaoweiChan in #456
  • chore: salvage uncommitted fixes stranded in agent worktrees by @HaoweiChan in #486
  • feat: TKB-001 — ticker/sector mention performance surfaces by @HaoweiChan in #487
  • docs(secrets): make GSM the designated home for secrets, cancel P6 step 5 by @HaoweiChan in #489
  • feat(social): copy an episode summary for 方格子 / Substack by @HaoweiChan in #490
  • feat(vocus): publish episode summaries to 方格子 by @HaoweiChan in #491
  • feat(frontend): add Google AdSense Auto ads by @HaoweiChan in #488
  • feat(substack): stage episode summaries as Substack drafts by @HaoweiChan in #492
  • release: AdSense + vocus publishing + social syndication → staging by @HaoweiChan in #493

Full Changelog: v0.9.1...v0.10.0

v0.7.0 — Topics & sectors overhaul, stock-page 所屬產業與題材 card, whole-market screener

Choose a tag to compare

@HaoweiChan HaoweiChan released this 11 Jul 16:14
a3074bb

v0.7.0 — Topics & sectors, reimagined

A big release focused on how you explore Taiwan's market by theme and sector, plus sharper podcast summaries.

🗺️ Topics & sectors, rebuilt

The whole 主題/產業 experience is rebuilt on a faster, more accurate data backbone:

  • Money-flow view — see where capital is rotating across sectors.
  • 三大法人 institutional data — foreign / trust / dealer trading now powers the topic and sector views.
  • Restructured topic navigation and consistent bubble charts across every page.
  • Sector "tide" visualization and sector discussion heat — see which themes the market (and podcasts) are actually talking about.

🏷️ Sector taxonomy on every stock page

  • New 所屬產業與題材 card on each stock page: which sectors and themes a stock belongs to, plus its 成分股 (board members).
  • Backed by a single curated registry that's now the source of truth — no more stale or conflicting sector labels.

🔎 Whole-market anomaly screener

  • A new screener scans the entire TWSE + TPEx universe for unusual price/volume moves, not just a watchlist.

🎧 Sharper podcast summaries

  • Summary pipeline quality pass: sections are no longer dropped or mislabeled, tags are cleaner, sector/tag duplicates are removed, and slide generation is reliable on regeneration.

📈 More accurate trending

  • Trending tickers now merge .TW / .US-suffixed rows into the bare ticker and run a full-market scan for a cleaner, more complete list.

🐛 Fixes & polish

  • Consistent deck / ticker cards across the app.
  • Closed gaps in podcast ingestion.

99 commits since v0.6.4. Full details in the merged PRs (#396#455).

v0.6.4 — Skippable segments, scheduled social posts, mobile fixes

Choose a tag to compare

@HaoweiChan HaoweiChan released this 11 Jul 16:14
1108eff

What's new in v0.6.4

🎧 Skip the filler in episodes — Podcasts now support skippable segments with a one-tap skip button, so you can jump past ads and intros straight to the content.

📅 Schedule social posts — The admin social tools can now schedule posts ahead of time instead of publishing immediately.

📱 Mobile fixes — Topic bubble tooltips are now touch-friendly on phones, and promo image uploads no longer fail with 403/422 errors.

🧹 Cleaner trending — Trend candidates now filter out stopwords, known tech tags, and company names, so the trending list is more relevant.

Under the hood — Hardened the sector-taxonomy sync so a shared-database restart can never overwrite curated data.

v0.6.3 — Bubble chart overhaul, new podcasts, and SEO/social optimizations

Choose a tag to compare

@HaoweiChan HaoweiChan released this 27 Jun 17:30

TinBoker v0.6.3 — Bubble chart overhaul, new popular podcasts, and SEO/social optimizations.

✨ What's new

📊 Bubble chart overhaul

  • Timeframe toggles. Switch between 1D / 7D / 30D / 90D to analyze return periods directly on the topics board.
  • Interactive UI. Hover to view detailed tooltips with constituent data and click through directly to details.
  • Decay heat metrics. Implemented a time-decay discussion heat metric (7-day half-life) for a more accurate focus indicator.
  • Trending tags. A new "熱門標籤" section is now unified across both the Sector and Topic tabs.

🎙️ New podcast shows

  • Added ingestion for "兆華與股惑仔" and "韭菜畢業班" with canonical RSS feeds and a 60-day lookback backfill.
  • Fixed Spotify show link redirections and direct cover image URLs.

🎨 Optimized social sharing

  • Marp service restored. Resolved backend rendering connectivity (fixing the 502 error during card generation).
  • Cleaner layouts. Added episode titles as subtitles, auto-sized long text to prevent overlapping, and kept content clear of the brand watermark.

🔍 SEO & dynamic sitemaps

  • Dynamic OG tags. Social Open Graph metadata is now dynamically injected based on request routes via Cloudflare Pages Function.
  • Dynamic sitemap.xml. Expanded indexing coverage (articles, stocks, podcasters, tags) and updated robots.txt.

🔧 Under the hood

  • Implemented Tag Registry case- and separator-variant de-duplication, unified exposure IDs, and added Soft Wall route guards.

Deployed to https://tinboker.com