# Encore Shao — Personal Blog (Full Text) > Complete text of every article on blog.icmoc.com, in both English and Chinese. Generated automatically at build time from the same source Markdown that powers the site. --- # English Articles ## encore-skills: Stop Re-Teaching Your AI - URL: https://blog.icmoc.com/en/13-encore-skills-gitlab-loop - Date: 2026-07-29 - Tags: AI, DevEx, GitLab, Product - Excerpt: I kept re-teaching the same GitLab workflow to Claude Code, Cursor, and Codex, until an MR shipped with no issue number linked — so I built one skills library that talks to all three. It was a Friday afternoon and I had three terminals open. Claude Code on one client's repo, Cursor on another, Codex wired into a third project's CI. Same job in all three: read a GitLab issue, find the root cause, branch, fix it, review it, open an MR. I'd written the instructions for that loop three separate times, in three separate formats — a `CLAUDE.md`, a set of `.cursor/rules/*.mdc` files, an `AGENTS.md` — because each tool wants its process definition in its own shape. I tweaked the MR title convention that week. Titles needed the issue number now: `fix: #482 nil check on webhook payload`, not just `fix: nil check on webhook payload`. I updated `CLAUDE.md`. I forgot the Cursor rules. An MR went out three days later with no issue number in the title, and a reviewer on that project asked which ticket it closed. Small thing. Also exactly the failure mode you get when the same process lives in three places and only one of them is current. ## What I actually built The fix wasn't "be more careful next time." It was: stop maintaining three copies of the same process. `skills/` became the single source of truth — one `SKILL.md` per skill, plain markdown with frontmatter, no tool-specific syntax. Then three adapters: ```bash ./scripts/setup-claude.sh # symlinks into ~/.claude/skills/ ./scripts/setup-cursor.sh # generates .cursor/rules/*.mdc ./scripts/setup-codex.sh # generates AGENTS.md ``` Edit a skill once, regenerate the adapters, commit both. The repo dogfoods itself — its own `CLAUDE.md`, `AGENTS.md`, and `.cursor/rules/` are generated from its own `skills/` directory, so whichever tool I'm using to work on the skills library is itself using the skills. Ten skills came out of this, split into two loops. ## The two loops The first is for PMs and designers, and it assumes zero codebase access: ``` write-issue → share → gather-feedback → refine → validate → finalize ↑ | └───────── iterate ────┘ ``` `write-issue` turns a rough idea into a structured GitLab issue. `pm-workflow` runs the rest — share it, gather feedback from users and stakeholders, refine, validate, loop back if it's not there yet. It's done when an engineer can pick the issue up and start without asking a single clarifying question. That's the actual gate, not "the issue has a title and a description." The second is the one that bit me with the MR title bug: ``` write-issue → analyze-issue → fix-issue → review-code → create-mr → [merge] ↑ | └──────────────────────── new issue from feedback ────────────────┘ ``` `eng-workflow` runs this as a single guided session, phase by phase, and each phase has a gate that has to be true before the next one starts: root cause identified before you write code, the problem confirmed gone (not just tests green) before you call it done, verdict "ready to merge" with no open blockers before `create-mr` runs, and the MR targeting the branch you actually came from before it opens. `summarize-issue` and `triage-issue` sit off to the side of the main loop — one posts a recap once the MR exists, the other replies to comments on an issue that's already in flight. ## Installing it Skills live in one repo and get adapted per tool with a single install script: ```bash # Claude Code — CLI, Desktop, and IDE extensions curl -fsSL https://raw.githubusercontent.com/encoreshao/encore-skills/main/scripts/setup.sh | bash -s -- --claude # Cursor — run inside the project you want it in cd /your/project curl -fsSL https://raw.githubusercontent.com/encoreshao/encore-skills/main/scripts/setup.sh | bash -s -- --cursor # Codex — also run inside the project, generates AGENTS.md cd /your/project curl -fsSL https://raw.githubusercontent.com/encoreshao/encore-skills/main/scripts/setup.sh | bash -s -- --codex ``` Every one of those one-liners clones (or updates) a checkout to `~/.encore-skills` on its own — there's no separate `git clone` step regardless of which tool you're installing for. Running the same command again later upgrades in place. ## Wiring up GitLab, once Every skill that talks to GitLab reads from the same config, so this only needs doing once per machine. From Claude Code, after restarting it post-install: ``` /gitlab-config ``` It walks you through adding your GitLab instance URL and a personal access token with `api` scope. From Cursor or Codex, the equivalent is just asking in plain language — "run the gitlab-config skill to set up my GitLab access" — since there's no slash-command layer in those tools. Multiple GitLab instances (a self-hosted one for work, gitlab.com for an open-source side project) live in the same config file under separate names, and every skill picks the right one from the project you're standing in. ## Using it Day to day this looks less like running a tool and more like saying what you have. Drop into a project with an open issue and say `analyze this issue` or `/eng-workflow #482` — the skill figures out from context whether you're starting fresh or picking back up mid-loop. Same sentence works in Claude Code, Cursor, or Codex, because the skill definition is the same file underneath all three; only the wrapper reading it differs. ## The part that took longest to get right The easy version of `create-mr` just assumes every branch targets `main`. That breaks the moment a team branches off `staging` or `develop`, which is common enough on client projects that I hit it in the first week of dogfooding. The fix touches two skills, not one: `fix-issue` checks what branch you were on before it cuts `/-`, and records that originating branch. `create-mr` reads it back and targets the MR there instead of guessing `main`. Neither skill can solve this alone — the information has to survive the handoff between them, which is a different problem than either skill's own logic. The other piece I underestimated was memory. `analyze-issue` and `triage-issue` both need to know things about a GitLab project — who's on it, what an issue's comment history looks like, what I concluded last time I analyzed something similar. Without a cache, every run re-fetches and re-derives that from scratch, which is slow and, worse, means an issue's comment thread from an hour ago doesn't inform the reply I'm drafting now. So `gitlab-config` grew a local cache under `~/.gitlab/cache/`, layered by instance, group, project, and issue. The `sync-issue` command still calls the GitLab API every time — new comments are never missed — but merges onto whatever's already cached, matching by note id, instead of discarding history and starting over. A group like `acme/rocket` holding both `rocket-web` and `rocket-mobile` gets its team roster synced once and shared across both projects, instead of fetched twice. ## Watching it actually work The moment that told me this was working wasn't a demo. It was running `triage-issue` against a real issue with eleven comments, most of them not addressed to me. The skill correctly picked out the two that tagged me directly, ignored a back-and-forth between two other engineers that didn't need my input, and drafted replies grounded in the actual current state of the code — not a guess, an actual read of the file the comment was asking about. I'd expected to spend twenty minutes editing the drafts down. I spent two, mostly changing tone in one sentence. The other one was smaller but stuck with me more. `create-mr` pulled up the issue thread, found a comment from three weeks earlier linking a related MR that had already fixed half the problem, and added a "Related" section to the description pointing the reviewer at it. I hadn't told it to look for that. It read the thread because the skill says to, and found something I would have had to remember was there. ## What actually changed I went in thinking the hard problem was getting an AI agent to write correct code and open a clean MR. That part turned out to be the easy part — all three tools are perfectly capable of following a well-written process. The actual problem was that I'd never written the process down in a form that survived being copied between tools. Every tweak to "how we open an MR" or "when to reply to a comment" lived in my head, got typed into whichever tool I happened to be using that day, and drifted from the other two within a week. A `SKILL.md` file forces the process into something explicit enough to be portable, and portable enough that fixing it once actually fixes it everywhere. That's a smaller idea than "AI writes your code for you," and it's the one that's held up after a month of using this on real client work. `encore-skills` is on GitHub at [github.com/encoreshao/encore-skills](https://github.com/encoreshao/encore-skills), MIT licensed, install script included. --- ## More Than a Decade Building a VC Intelligence Platform - URL: https://blog.icmoc.com/en/11-vc-intelligence-platform - Date: 2026-06-24 - Tags: Rails, Product, AI, Infrastructure, VC - Excerpt: What it actually takes to build a venture capital intelligence platform — data quality problems, 30+ integrations, and the infrastructure that makes it run at 3am. The first time the meeting prep feature worked end-to-end, it was 6:58am on a Tuesday. A Slack notification appeared in a channel before anyone arrived at the office. It had the company name, the funding history, the headcount trend, the LinkedIn relationship chain between the founders and the investment team, and a note from a call logged in Salesforce fourteen months earlier. No one assembled it. No one asked for it. The system ran on a schedule, pulled from six different sources, packaged everything into a document, and posted it automatically. I'd been building toward that moment for two years. The calendar integration, the relationship graph, the entity data pipeline, the Slack bot — each piece had existed separately before that morning. Getting them to work together, end-to-end, without breaking anything, was not something I took for granted. ## How We Got Here We came in as a technology partner. The firm had a deal-sourcing problem — not a shortage of signals, but a surplus of disconnected ones. Data lived in a CRM that didn't talk to email. LinkedIn sat in one tab. Crunchbase in another. Someone's notes from a founder call were in a notebook somewhere. Our job was to simplify that workflow, improve data quality, and eventually introduce AI where it could actually help. That turned out to be a decade-long engagement. ## What We Built The short version: a deal-sourcing and relationship intelligence platform for a venture capital firm. The longer version: a shared Rails engine running in the background, watching thousands of companies, pulling data from 30+ sources, and building a picture of each company that the investment team can actually use. It powers six applications — the main web interface, a dedicated API, an inbound pipeline, pipeline review tooling, a data warehouse, and an OAuth service. All of them share the same data layer. The analysts who use it every day don't see any of this. That's the point. ## The Company Identity Problem The hardest thing I had to design wasn't any individual integration. It was a more fundamental problem: every data source has a different opinion about the same company. LinkedIn says a company has 250 employees. Crunchbase says 180. ZoomInfo says 310. SimilarWeb shows declining web traffic. Apptopia shows surging mobile downloads. They're all looking at the same company. They all disagree. The solution I built is what I call the Entity–Profile–Site triangle. An Entity is the canonical record for a company — one row, one domain name, one source of truth. A Profile is what a specific Site (a data source) says about that company. Crunchbase has its own profile for the company. LinkedIn has its own. Each holds raw JSONB data from that source, unmodified. Once a day, or whenever a profile updates, a derived data pipeline runs. It reads all the profiles for an entity, applies source-specific weights, blends time-series data, and writes a single `derived_data` column that downstream features can query against. Getting the aggregation logic right took years. The database has grown to over 800 migrations and roughly 300 tables. I index into that JSONB column with expression-based GIN indexes so analysts can filter on deeply nested metrics without watching a spinner for several seconds. ## Background Workers at Scale The data doesn't arrive. We go and get it. At any given time in production there are 27+ Sidekiq processes running, each owning specific queues. Every data source has its own job namespace: `Crunchbase::*`, `LinkedIn::*`, `Salesforce::*`, `Office365::*`, `Notion::*`, and on from there. Every source follows the same lifecycle: a scheduler fires a discovery job, that job calls the source's API client, raw data lands in a Profile, the system checks whether the profile already belongs to an Entity — and if not, fuzzy-matches it to one — derived data recomputes, features and scores update. What makes this reliable at scale is the unglamorous infrastructure around it. Rate limiters back off and retry when LinkedIn returns a 429. Redis-based single-thread locks prevent concurrent jobs from clobbering the same entity. A deduplication key blocks a job from running twice in the same day against the same target. Over the years this became a layered concern stack — job deduplication, throttling, single-thread execution, and credential rotation — that jobs compose by including the right modules. None of this shows up in a changelog. It's why the system runs at 3am without supervision. ## The People Problem Companies don't make decisions. People do. The platform tracks individuals — founders, executives, investors — across their career history, LinkedIn connections, email interactions, and relationship history with the investment team. The identity resolution problem here is genuinely hard. "John Smith, CEO at Acme Corp" in LinkedIn might be the same person as "Jonathan Smith" in Salesforce and "J. Smith" in an Office365 contact. I built four merge strategies to handle this: by email, by nickname and alias, by cross-email match, and by fuzzy name similarity. When it works, the investment team can see that a colleague had coffee with this founder two years ago, and another colleague is a first-degree LinkedIn connection to the CTO. That's the difference between a cold email and a warm introduction. ## The Integration That Aged My Hair Out of 30+ integrations, Salesforce is the one I could write a separate article about. Bi-directional sync sounds manageable in a design doc. In practice it means: entities push to Salesforce accounts, Salesforce contacts get pulled back as people profiles, task completions sync in both directions, account ownership changes propagate across both systems — all while users are actively editing records in both places simultaneously. I built a "dispatch center" pattern to orchestrate sync operations without triggering infinite update loops. Seven years in, it still produces new edge cases. I've made peace with that. ## When AI Showed Up For most of the platform's life, the intelligence was structural — funding signals, headcount growth, web traffic trends, review counts. Around 2022 we started layering in LLMs, and it changed what "useful" actually meant. The meeting prep feature I opened with is the clearest example. The night before a scheduled meeting, a job pulls the calendar event, identifies which company or person is involved, assembles everything the platform knows about them — funding history, headcount trend, recent news, relationship context — runs it through an LLM, and posts a briefing to Slack before the team walks in the door. No one schedules it. No one writes it. The AI does the synthesis; the structured data gives it something accurate to work with. That was the pattern for every AI feature we built: the model handles language, the pipeline handles truth. **Chatbot Q&A.** Analysts can ask natural-language questions about any company in the database — "what's their hiring trend in engineering?" or "who on our team knows someone there?" — and get answers drawn from live structured data, not a static document. The interface is conversational; the answers are grounded. **AI-drafted outreach.** When the investment team wants to reach a founder, the system drafts an opening email using relationship context: shared connections, prior interactions, the company's recent milestones. The analyst edits and sends. The AI handles the first draft so the human can focus on the actual relationship. **LLM narrative summaries.** Every entity profile now includes an AI-generated paragraph synthesizing the structured data into readable prose — what the company does, how it's been growing, what makes it notable right now. This lives alongside the raw metrics, not instead of them. **RAG retrieval with pgvector.** Investment memos, call notes, and Notion documents are embedded and stored as vectors. When a user asks a question, the system retrieves the most semantically relevant chunks before generating a response — grounding answers in the firm's own documents rather than the model's general knowledge. **Scoring models.** Multiple signals — growth velocity, team background, thesis alignment, relationship proximity — feed into composite scores that rank companies across the pipeline. These don't replace judgment; they change what gets seen first. An analyst reviewing 200 inbound companies in a week can't give each one equal attention. The scoring model surfaces the ones worth a second look. **Investment thesis classification.** A trained classifier reads a company's profile and predicts which of the firm's investment themes it fits best. This helps with inbound filtering, surfaces overlooked companies already in the database, and gives the team a shared vocabulary for pattern-matching across the portfolio. None of this replaced the structured data pipeline. It sits on top of it. Structured data gives the AI accurate grounding; the AI makes the structured data readable and actionable. You can't write a useful company summary if the underlying entity record is dirty. Every AI feature we added made me appreciate the years spent cleaning and normalizing data more, not less. ## What More Than a Decade Taught Me The thing that surprised me most: data quality is a product problem, not just an engineering problem. The hardest bugs weren't in the code. They were in the data — a company that changed its name, a domain that got quietly acquired, a person who changed jobs three times in two years. The system needs opinions about identity, and those opinions need to be constantly questioned and updated. No amount of good code compensates for a dirty canonical record. The boring infrastructure mattered more than I expected. Job deduplication, Redis locks, rate limiting, idempotent writes — none of it makes the changelog exciting. It's why the system runs reliably. And working closely with an investment team for this long reshaped how I think about technology partnerships. The analysts don't think in data models. They think in relationships, in pattern recognition, in the feel of a founder conversation. Building something they'd actually reach for — rather than something technically correct but quietly ignored — meant staying close to the actual work, not just the requirements doc. The technical complexity here is real. But the harder skill is knowing which complexity is worth building and which can wait. That judgment only comes from staying in the problem long enough to stop being surprised by it. --- ## Setting Up PostgreSQL 10 Streaming Replication the Hard Way - URL: https://blog.icmoc.com/en/10-postgres-replication - Date: 2026-06-19 - Tags: PostgreSQL, Infrastructure, DevEx - Excerpt: What it actually takes to get Postgres streaming replication working between two local VMs — without skipping the parts that matter. The team had one Postgres instance. No replica. No failover. Just one server doing everything, and everyone pretending that was fine. Nobody was lying exactly. We had backups. We had a plan. The plan was "restore from backup if the server dies" — which meant downtime measured in hours, not minutes. That kind of plan sounds reasonable until you're the one executing it at 2am. I wanted to understand replication before I was in a position where I needed to urgently. So I spun up two VirtualBox VMs with Vagrant — Ubuntu 18.04, PostgreSQL 10 — and spent a weekend on it. ## The Setup Two machines, two IPs: - Master: `192.168.33.11` — takes reads and writes - Slave: `192.168.33.22` — read-only standby, follows the master's WAL stream Vagrant made this easy to tear down and start over, which I did more than once. The Vagrantfile is three lines once you strip out the comments: ```ruby config.vm.box = "ubuntu/bionic64" config.vm.network "private_network", ip: "192.168.33.11" config.vm.hostname = "pg-master" ``` Same for the slave, different IP and hostname. ## Configuring the Master Every change on the master — insert, update, delete — gets written to the WAL before it touches the actual data files. That log exists for crash recovery. It's also the stream the slave subscribes to. Your job on the master is to expose it and give the slave permission to read it. Start by creating a replication user: ```sql CREATE ROLE replicate WITH REPLICATION LOGIN PASSWORD 'your-password'; ``` Then edit `/etc/postgresql/10/main/postgresql.conf`: ```conf listen_addresses = '*' wal_level = replica max_wal_senders = 3 wal_keep_segments = 64 ``` `wal_level = replica` is the key one. Without it, Postgres won't generate the WAL data needed for streaming replication. `max_wal_senders` caps how many slaves can connect. `wal_keep_segments` controls how many WAL segments to keep around — enough runway for the slave to catch up if it falls behind. The other file that trips people up is `pg_hba.conf`. You need to explicitly allow the slave to connect for replication: ```conf host replication replicate 192.168.33.22/32 md5 ``` That line has to use `replication` as the database name, not `all` or your app database. It's a special keyword Postgres uses for replication connections. Miss it, and the slave connection fails with a cryptic auth error that gives you almost no signal about what went wrong. Restart Postgres on the master after both config changes. ## Configuring the Slave This is where most tutorials lose me. They tell you to "set up the slave" like it's two steps. It's not. First, stop Postgres on the slave: ```bash sudo service postgresql stop ``` Then delete the entire data directory: ```bash rm -rf /var/lib/postgresql/10/main/* ``` You're not wiping data you care about — this is the empty directory Postgres created during installation. You're clearing space for the base backup that's about to come from the master. Set the permissions back: ```bash chmod 700 /var/lib/postgresql/10/main chown postgres:postgres /var/lib/postgresql/10/main ``` Now run `pg_basebackup` as the postgres user: ```bash sudo -u postgres pg_basebackup \ -h 192.168.33.11 \ -D /var/lib/postgresql/10/main/ \ -P -U replicate \ --wal-method=stream ``` This copies the entire master data directory to the slave, including a consistent snapshot of the WAL position at the time of backup. The `-P` flag shows progress. It'll take a few seconds for a fresh install, longer in production. After the backup, create `/var/lib/postgresql/10/main/recovery.conf`: ```conf standby_mode = 'on' primary_conninfo = 'host=192.168.33.11 port=5432 user=replicate password=your-password' trigger_file = '/tmp/MasterNow' ``` `standby_mode = 'on'` tells Postgres to stay in recovery mode and keep following the WAL stream instead of completing recovery and becoming a standalone primary. `trigger_file` is the failover mechanism — create that file on the slave and it stops following the master and promotes itself. Start Postgres on the slave: ```bash sudo service postgresql start ``` ## Verifying It Works Start with the connection check. On the master: ```sql SELECT * FROM pg_stat_activity WHERE usename = 'replicate'; ``` One row means the slave connected and the stream is live. No rows means the slave never got through — go back to `pg_hba.conf`. That's where I had the wrong entry on my first attempt, and the auth error from the slave gave almost no hint why. Once the connection is up, the real test is a write. Create a database on the master: ```sql CREATE DATABASE replication_test; ``` Then connect to the slave: ```sql SELECT datname FROM pg_database WHERE datname = 'replication_test'; ``` It was there within a second. Everything I'd configured — the WAL level, the replication role, the base backup, the recovery.conf — reduced to one row appearing on a different machine. That's the moment the mental model stops being abstract. If it doesn't appear, the Postgres logs on both machines are specific enough to tell you exactly which step failed. ## What I Took Away What surprised me was how much the sequence depends on each step being exactly right — not just correct, but in the right order, with the right permissions, as the right user. None of the steps fail loudly. They fail quietly, and the next step proceeds as if nothing happened. The piece I'd underestimated was `recovery.conf`. It's not configuration — it's mode selection. Postgres reads it at startup and decides whether to behave as a standby or a primary. Delete it on a standby you want to promote: that's failover, working as designed. Delete it by accident and you have two independent primaries writing to the same application. The file carries none of that weight in its name. The trigger file taught me something different. In production nobody touches it directly — Patroni and repmgr handle failover automatically. But they're built on top of this primitive, and understanding it meant I could read their documentation instead of just following it. I deleted the Vagrant setup a week after I got it working. When the infrastructure team conversation eventually happened, I didn't need anyone to explain WAL segments or `max_wal_senders`. I already had a frame for it. That saved more time than the weekend cost. --- ## Building github-trending: From a Cron Script to a React App - URL: https://blog.icmoc.com/en/09-github-trending - Date: 2026-06-13 - Tags: Open Source, API, React, DevEx - Excerpt: I wanted to track what was rising on GitHub each week without manually scrolling. A Node.js script became a full React app — here's what that journey looked like. Every Monday morning I'd open GitHub's trending page, skim the top repos, and try to remember what looked new versus what had been sitting there for weeks. The page mixes everything together — repos trending today, this week, this month — and gives you no way to filter, export, or compare across time. After a few months of this I stopped trusting my own memory and started writing it down. The first version was twelve lines of JavaScript. ```js const response = await axios.get('https://api.github.com/search/repositories', { headers: { Authorization: `token ${GITHUB_TOKEN}` }, params: { q: 'created:>=' + getLastWeekDate(), sort: 'stars', order: 'desc', per_page: 20, }, }); ``` That's the heart of it. The GitHub Search API accepts a `created:>=` qualifier, so you can ask for repos created in the last seven days, sorted by stars. It's not exactly "trending" in GitHub's own sense — but it's a proxy that's good enough. Repos that go from zero to hundreds of stars in their first week are the ones worth watching. The script saved results to `docs/YYYY/MM/date.json` and a matching CSV, then I set it on a cron job to run every Sunday night. ```bash 0 21 * * 0 cd /path/to/github-trending && node index.js ``` That worked for about three months. I had a directory of JSON files accumulating, which I'd open in VS Code and search through. Not ideal. When I wanted to compare what was trending in January versus March, I was manually diffing files. ## The UI wasn't optional anymore The data collection was solved. Exploring it wasn't. I built the React app mostly because I wanted two things the raw files couldn't give me: a table with sortable columns, and a way to filter by language or topic without writing a grep command. Ant Design 5 handled most of the table work — `Table`, `Input`, `Select`, the standard toolkit. The interesting UI problem was the dual view mode. Researchers and developers use the data differently. A developer scanning for something to star wants cards — repo name, description, star count, one click to the URL. A researcher or analyst comparing many repos at once wants a dense table with all twenty fields visible. I built both views and let users switch between them, with the same underlying dataset. ``` Card view → visual browsing, one repo per card Table view → data analysis, all fields visible, sortable, filterable ``` The fields you can display are the full set the GitHub API returns: name, owner, avatar, description, topics, license, all the URL variants, created/updated/pushed dates, stars, forks, open issues, size, primary language. Twenty-plus attributes. Most people use maybe six. ## The rate limiting problem GitHub's Search API caps unauthenticated requests at 10 per hour. With a personal access token and `public_repo` scope, that goes to 30 per hour — still not a lot if you're paginating through results aggressively. My initial approach was to fetch everything on page load. That hit the rate limit inside a day of showing the app to colleagues. The fix was two things: cache aggressively on the client side using `localStorage`, and add explicit feedback when the rate limit kicks in rather than silently returning an empty response. The settings panel stores the token, display field selection, and page size in `localStorage`. Nothing goes to a server. The token never leaves the browser. That was a deliberate choice — I didn't want to run a backend just to proxy GitHub API calls, and I didn't want to ask users to trust me with their credentials. ## What got interesting The query design took a few iterations. The naive version used GitHub's own trending endpoint. Except that endpoint isn't part of the official API — it's a scrape target that changes without notice and blocks unusually frequent access. I'd used it in an earlier version and got burned when GitHub quietly restructured the page markup. Switching to the Search API with `created:>=YYYY-MM-DD` was more reliable, but it measures something different. A repo created two years ago that suddenly gets attention won't appear. That's a tradeoff I decided to accept. The point of the tool was discovery — new things rising fast — not monitoring existing repos for sudden spikes. The output formats matter more than I expected. I thought JSON would be enough. Then a PM asked for a spreadsheet. CSV export via `papaparse` took an afternoon and removed about four "can you export this for me?" requests per month. ## Where it sits now The web app is live at [github.ranbot.online](https://github.ranbot.online). The cron script still runs every Sunday. The `docs/` directory has almost two years of weekly snapshots now, which I keep meaning to turn into something more interesting — trend lines, language shifts over time, that kind of thing. The project didn't start with a clear scope. It started with me being annoyed at a manual process. That's still the most reliable source of tools worth building. --- ## Building bamboohr-mcp: An MCP Server for HR APIs - URL: https://blog.icmoc.com/en/01-bamboohr-mcp - Date: 2026-06-01 - Tags: AI, MCP - Excerpt: I wanted to let any LLM talk to BambooHR's API. Three days later I had a production MCP server in TypeScript. Here's exactly what I built and what surprised me along the way. Every Friday afternoon, someone on our HR team would ping me. "Can you pull the headcount by department?" "Who's out next week?" "How many hours did the engineering team log in April?" They weren't being unreasonable. The data was all in BambooHR. It's just that getting it out required logging in, navigating to the right report, setting date filters, exporting a CSV, and sometimes doing a bit of spreadsheet math. Not hard — just tedious. And it kept falling on me because I knew where everything was. I'd been watching the Model Context Protocol ecosystem grow since Anthropic released the spec. The premise is elegant: you write a server that exposes "tools", and any MCP-compatible client — Claude, Cursor, Windsurf, whatever — can discover and call those tools in natural language. I thought: what if the HR manager could just ask Claude "who's on leave this week?" and Claude would go fetch that answer itself? That's what became `bamboohr-mcp`. ## Starting with the right question Before writing any code, I had to decide what the server should actually do. BambooHR's API has over 80 endpoints — employee directory, time tracking, benefits, performance reviews, hiring pipelines, org charts, custom reports. I could try to wrap all of it. That would have been a mistake. The question isn't "what can BambooHR do?" It's "what does our team actually ask about?" I spent an afternoon going through the last three months of HR-related pings in Slack and categorized them. The answer was clear: - Who's out / who's working today - Project and task tracking (for timesheet purposes) - Employee directory lookups - Time entry submission and review - Basic org structure That's roughly 20% of the API surface, but it covered probably 90% of the real requests. I decided to ship 12 tools and not look back. ## The build I chose TypeScript over Python because the BambooHR API is REST-based JSON and Node's HTTP ecosystem is excellent. The MCP SDK from Anthropic made the tool definition straightforward. Each tool follows the same shape: ```typescript { name: "fetch_employee_directory", description: "Get all active employees with name, email, job title, and department", inputSchema: { type: "object", properties: { status: { type: "string", enum: ["Active", "Inactive"], description: "Filter by employment status" } } } } ``` The implementation side is just a typed fetch call wrapping BambooHR's REST endpoints, with error handling and response normalization. The hardest part architecturally was authentication — BambooHR uses API key auth with a company domain prefix in the URL. I wired this up via environment variables so the server can be configured per-deployment without touching code. ```typescript const token = process.env.BAMBOOHR_TOKEN!; const companyDomain = process.env.BAMBOOHR_COMPANY_DOMAIN!; const employeeID = process.env.BAMBOOHR_EMPLOYEE_ID!; ``` The tools I ended up shipping: 1. `fetch_employee_directory` — all employees with basic info 2. `fetch_whos_out` — current and upcoming time-off 3. `fetch_projects` — project and task list for time tracking 4. `fetch_time_entries` — time logs for a given period 5. `submit_work_hours` — submit time entry for a project/task 6. `get_me` — current user's info from the token 7. `fetch_timesheet` — daily timesheet view 8. `fetch_departments` — org structure 9. `fetch_job_titles` — list of roles 10. `fetch_benefits_summary` — benefits overview 11. `fetch_pto_balance` — remaining leave balance 12. `fetch_holidays` — company holiday calendar ## What I got wrong the first time My first version didn't handle pagination. BambooHR returns paginated results for employee lists, and I was silently returning only the first page. In a 30-person company that's fine. In a 300-person company, you'd get incomplete data with no warning. I fixed this by building a generic `fetchAllPages` helper that follows the `Link` headers. I also underestimated how much the response format matters for LLM consumption. Raw BambooHR API responses have deeply nested objects with inconsistent field names. An LLM can parse them, but it works much better when you normalize the data into flat, clearly named objects. I ended up writing a lightweight transform layer for each tool's response. ## The surprise Three days after deploying this internally, our HR manager started using it. Not via a developer — she connected Claude Desktop to the MCP server herself using the configuration file approach, and started asking questions directly. Her first real query: "Which engineers haven't submitted their timesheets for last week?" Claude called `fetch_time_entries` with a date range, fetched the employee directory, did a set difference, and replied with a list of names. The query that used to take me ten minutes took her thirty seconds. That was the moment I understood what MCP actually is. It's not an AI feature. It's an integration layer that makes your existing systems legible to any LLM without building a custom UI for every use case. ## What I'd do differently **Rate limiting.** BambooHR's API has rate limits, and a poorly-phrased question can cause Claude to make a lot of tool calls in quick succession. I'd add a simple token bucket on the server side. **Better error messages.** When authentication fails, the current error is "401 Unauthorized" — not helpful when you're debugging why Claude can't connect. I'd surface the actual BambooHR error response. **Versioning strategy.** I haven't thought about API versioning at all. When BambooHR deprecates an endpoint, the server will silently break. I'd add a health-check tool that validates all endpoints are reachable. The repo is at [github.com/encoreshao/bamboohr-mcp](https://github.com/encoreshao/bamboohr-mcp) if you want to run it yourself or adapt it for a different HRIS. The pattern generalizes easily — swap BambooHR for any REST API with an auth token and you have the skeleton of an MCP server in an afternoon. --- ## AI-Powered Bookmark Dashboard: A Chrome Extension Story - URL: https://blog.icmoc.com/en/03-bookmark-dashboard - Date: 2026-04-15 - Tags: AI, Chrome - Excerpt: Replacing the new tab page with something actually useful — how I built a bookmark dashboard powered by agentic AI, and what I'd do differently. I have 800 bookmarks. Most of them are useless. But somewhere in that pile are links I saved with genuine intention — documentation I wanted to read, tools I meant to try, articles I was going to come back to. Every time I opened a new tab and saw Chrome's default page, I was reminded that those 800 links existed and that I was doing nothing with them. The Bookmark Dashboard started as a frustration project. Six months later it's a real extension on the Chrome Web Store, and building it taught me more about the state of browser extension development than I expected. ## The idea The pitch is simple: replace the new tab page with a full-featured bookmark manager. Every bookmark, immediately accessible. Search, tags, folders, pinned items — all without opening a separate app or going to the bookmarks menu. The AI layer came later. Once you have access to a user's entire bookmark library in a single JavaScript context, AI starts to become genuinely useful: - **Duplicate detection**: find bookmarks with the same URL or near-identical titles - **Dead link cleanup**: check which URLs return 404 or have changed - **Tag suggestions**: analyze page content and suggest relevant tags - **One-click reorganization**: propose a better folder structure based on content This is "AI-powered" in the real sense — the AI does actual work that would be tedious to do manually, not just surfaces a chatbot. ## Architecture decisions Chrome extensions have a peculiar programming model. You have three distinct execution contexts: - **Background service worker**: runs persistently (or on demand), has full Chrome API access, no DOM - **Content scripts**: injected into web pages, have DOM access, limited Chrome API access - **Extension pages** (popup, new tab override, options): regular web pages with Chrome API access The new tab override lives in the extension page context, which means you get a full React app running on every new tab. I chose React 18 for the UI and TypeScript throughout — not because it was necessary, but because the bookmark library manipulation code gets complex quickly and types catch the bugs that would otherwise surface as "the dropdown shows the wrong folder." The AI features use Claude's API via the background service worker. Direct API calls from the extension page would expose the API key in network requests — background proxies the calls so the key only lives in the extension's storage. ## What Chrome makes hard Manifest V3 (the current extension platform) is significantly more restrictive than V2. Service workers don't persist — they start on demand and shut down after inactivity. Any state you need to survive a service worker restart has to go to `chrome.storage`. The most annoying constraint: you can't make arbitrary network requests from a service worker to check if URLs are alive. Chrome's declarative content policy blocks most outbound fetch calls by default. I had to use the `offscreen` API — a V3 feature that creates an off-screen document — to run URL validity checks through a DOM `fetch`. It's a roundabout workaround that adds complexity for a straightforward use case. Folder operations in the `chrome.bookmarks` API are also surprisingly limited. You can move bookmarks, but bulk operations require looping with individual API calls — there's no batch move. For a user with 800 bookmarks, reorganizing a folder is noticeably slow. ## The feature nobody asked for vs the one everyone uses I spent the most time on the AI reorganization feature — the one that analyzes your bookmark structure and proposes a better folder hierarchy. It's technically the most interesting part of the codebase. Almost nobody uses it. The feature everyone uses is basic search. Full-text search across all 800 bookmarks, returning results as you type, with keyboard navigation. It took me two days to build. It's the first thing people mention in reviews. This is the classic product lesson: the feature that solves a daily friction point beats the feature that solves an occasional big problem. Reorganizing bookmarks is something you do once a year. Searching for a bookmark is something you do multiple times a day. ## What I'd do differently **Better offline handling.** The AI features require network access, but search and basic management should work offline without degradation. I didn't think about this until users on airplane mode reported broken experiences. **Sync between browsers.** The tag layer is stored in extension storage, which doesn't sync to Chrome Sync by default. Users who switch between their laptop and desktop find their tags missing on the second machine. `chrome.storage.sync` has a 100KB limit — plenty for most users — but I initially used `chrome.storage.local` without thinking about cross-device use. **Test the performance edge cases earlier.** My development machine has 200 bookmarks. The first user who installed with 3,000 bookmarks told me the new tab page took four seconds to load. I had to rethink the rendering approach to virtualize the list. The extension is on the Chrome Web Store and the source is at [github.com/encoreshao/bookmark-dashboard](https://github.com/encoreshao/bookmark-dashboard). Try it if you've ever looked at your bookmarks bar and felt vaguely guilty about the mess. --- ## Agentic AI in Production: What I Learned Building RanBOT - URL: https://blog.icmoc.com/en/04-agentic-ai-ranbot - Date: 2026-03-20 - Tags: AI, Agents - Excerpt: Autonomous workflows sound great until they hit the real world. Twelve months of running agentic systems in production, and the lessons I keep coming back to. RanBOT started as a side experiment that turned into something I actually use every day. The idea was simple: an AI bot platform where you define workflows in natural language and the system figures out the execution. No code, no integrations to maintain — just describe what you want done and let the agents handle it. Simple ideas are always more complicated in practice. ## What RanBOT actually is At its core, RanBOT is a multi-agent orchestration platform. You create "bots" — each bot is a set of instructions, a set of tools (web search, API calls, file operations, notifications), and a trigger (schedule, webhook, or manual). When a bot runs, it gets an LLM doing the reasoning and a set of tools it can call to interact with the outside world. The use cases we built it for: competitive monitoring (check competitor pricing daily), content summarization (morning briefing of news in specific categories), data enrichment (take a list of company names, look up their recent funding), workflow automation (when this Slack channel gets a message with keyword X, do Y). These aren't novel ideas. What made RanBOT interesting was the attempt to make them accessible without programming. ## The first production failure Three weeks after launch, a bot running competitive price monitoring started making hundreds of API calls per minute. The trigger was a rate-limiting error — the bot received a 429, and instead of backing off, it retried immediately, got another 429, and interpreted the repeated failure as a signal that it needed to try harder. It escalated its retry frequency. We called this the "panic loop." The agent had no concept of "back off when throttled." It only had the goal (get the data) and a set of tools (make HTTP requests). When the tool kept failing, the agent kept trying the tool. This was a fundamental design gap. We'd thought about what agents should do when they succeed. We hadn't thought carefully enough about what they should do when they systematically fail. The fix required adding explicit failure handling to the agent's context — not just tool-level errors, but semantic categories of failure: "rate limited, wait before retrying," "authentication failed, escalate to human," "data source unavailable, skip and log." The agent needed a vocabulary for different failure modes before it could respond to them correctly. ## Three patterns that actually work After twelve months of running agents in production, three patterns have become standard in everything we build. **Human-in-the-loop checkpoints.** For any action with real-world side effects — sending an email, posting to social media, making a purchase — we add a confirmation step. The agent drafts the action, sends a preview notification, and waits for explicit approval before executing. This feels like it defeats the purpose of automation. In practice, it's what makes the automation trustworthy enough to actually use. The 30 seconds it takes to approve an email is faster than manually writing it. **Narrow tool scope.** Early bots had access to everything. Later bots have access to only what they need. A bot that monitors competitor prices doesn't need write access to your database, and giving it that access is asking for an incident. We now define tool sets per bot type and require explicit justification for any write or delete capability. **Explicit state logging.** Every decision the agent makes — every tool call, every reasoning step, every branch in the workflow — gets logged with enough context to reconstruct what happened and why. When something goes wrong (and it will), you need to be able to read back the agent's "thinking." Without this, debugging is archaeology. ## Where agents still break down **Long-horizon tasks.** The further the goal from the immediate context window, the worse agent performance gets. A bot that does research and synthesis over multiple hours will lose coherence. It forgets earlier findings, revisits the same sources, contradicts itself. This isn't fixable with better prompting — it's a fundamental limitation of how LLMs handle extended sequences. **Ambiguous success criteria.** "Find me good articles about X" is harder for an agent than "find me articles about X published in the last 30 days with at least 1,000 words." The more ambiguous the success condition, the more the agent will hallucinate confidence that it's done a good job. Define completion conditions precisely. **Adversarial content.** Web pages are not neutral. Some are designed to manipulate automated systems — prompt injection in meta tags, misleading structured data, content that tells any AI reading it to take specific actions. A bot that browses the web needs defenses against this, which is harder than it sounds. ## What I think is actually happening The framing of "agentic AI" can mislead you into thinking the system is autonomous in the way a person is autonomous. It isn't. A well-designed agent is a function with a very rich input type and a lot of internal loops. It has goals and tools, but it doesn't have judgment the way a human does — it has pattern matching at scale. The most reliable agents I've built are the ones that do one thing and do it with tight constraints. The least reliable are the ones I gave the most freedom. That's counterintuitive if you're thinking about agents as autonomous workers. It makes perfect sense if you're thinking about them as functions. RanBOT is at [ranbot.online](https://ranbot.online) if you want to try it. The interesting work isn't the framework — it's designing the workflows to take advantage of what agents are actually good at. --- ## The Whiteboard That Fired My Video Editor - URL: https://blog.icmoc.com/en/14-excalidraw-recording - Date: 2026-02-15 - Tags: Product, TypeScript, DevEx - Excerpt: I kept losing forty minutes to captions and cropping after every client walkthrough, until I built a whiteboard that records, transcribes, and exports itself. I was three tabs deep trying to record a five-minute walkthrough for a client. Excalidraw open in one tab for the diagram, QuickTime running in the background for the screen capture, and a sticky note on my monitor reminding me to talk slower because the last take had no captions and the client's team lead is hard of hearing in one ear. I finished the recording, opened it in a video editor, and spent the next forty minutes burning in a subtitle track by hand for a clip that was supposed to take five. That wasn't a bad day. That was a normal Tuesday. I'd been doing some version of this for months — sketch, record, edit, apologize for the delay — and every time I told myself the next one would be faster, and it never was. ## Why nothing off the shelf fixed it I'd looked at the obvious options before, more than once. Loom is built for exactly this kind of walkthrough, but it uploads to Loom's cloud the second you stop recording, and half of what I sketch for clients is architecture I have no business putting on someone else's server mid-draw. Excalidraw plus OBS solves the drawing and the recording separately, which means two apps, two sets of settings, and captions that don't exist unless I add a third tool for them. Miro and Whimsical do the whiteboard, occasionally the recording, never the whole chain at once, and always behind a subscription that assumes a team, not one person recording a client call alone. None of those were wrong, exactly. They were each built to solve a piece of the problem I had, and the piece missing was always the one I needed that week. What I actually wanted was smaller and more specific than any of them offered: draw, talk, and get a finished video, in one place, without anything leaving my machine until I choose to export it. By the time I sat down to build it, the decision wasn't really a decision. I'd done the manual version of this workflow often enough that the forty minutes of post-editing had stopped feeling like a one-off tax and started feeling like a bug in how I worked. Building the fix took less time, cumulatively, than living with the workaround for another quarter would have. ## What it turned into I started from an empty repo on February 6th and had something I was using on real client calls nine days and seventeen commits later. [Excalidraw](https://github.com/excalidraw/excalidraw) does the whiteboard part — full sketching, shapes, frames, no login wall — better than I'd ever build myself, so I left that alone and built everything around it. It's live now at [video.ranbot.online](https://video.ranbot.online). ![Excalidraw Recording — whiteboard with camera bubble, live captions, and recording controls](https://raw.githubusercontent.com/encoreshao/excalidraw-recording/main/assets/images/recording.png) ## What's actually in it - **The whiteboard itself** — full Excalidraw: shapes, freehand drawing, frames, no login wall. - **A camera bubble** you drag anywhere on the canvas, resizable from the settings panel. - **Live captions** transcribed from your own voice as you talk, not added afterward. - **Cursor highlighting and spotlight effects** for the moment you're pointing at something specific. - **Aspect ratio presets** — 16:9 for YouTube, 9:16 for TikTok or Shorts, square, or a custom crop. - **Presentation mode** that turns Excalidraw frames into fullscreen slides, arrow keys or swipe to move between them. - **One-click export** straight to MP4 or WebM, no upload step in between. - **Everything client-side** — the drawing, the camera feed, the transcription, the encoding, all of it happens in your browser. Nothing leaves your machine until you hit export. Getting the canvas, the camera feed, and the floating caption text to land in the same exported file — rather than three separate things that happen to be visible on the same screen — was the one piece that took real engineering. The short version: none of it becomes a video until you deliberately draw all three onto one shared canvas and record that instead of the page itself. Get that wrong and the recording looks perfect on your screen while the camera bubble is simply absent from the exported file, which is exactly the kind of bug you only discover after you've already sent the video. ## The tool told me what it was missing The last few commits, on the 15th, added a presentation mode — Excalidraw frames turned into fullscreen slides you can step through with arrow keys or a swipe. That wasn't in any plan. It came from using the thing myself for a week and noticing that half of what I record for clients isn't "watch me draw live," it's "here are four boards I already sketched last night, let me walk you through them." I hadn't built for that case because I hadn't yet lived it enough times to notice it was a separate case. Once I did, it was an easy add — Excalidraw already organizes drawings into frames, I just hadn't pointed the recorder at that structure yet. That's the pattern the whole project followed. Every feature came from hitting the same wall twice, not from a spec. The circular camera bubble exists because a rectangular one kept covering corners of diagrams I needed visible. The caption-clearing logic exists because early captions piled up into an unreadable wall of text the first time I actually talked for more than thirty seconds straight. None of it was designed in advance. All of it was a direct fix for something that had just annoyed me. ## What actually changed I stopped opening QuickTime. Every client walkthrough and internal demo I've recorded since February 15th has come out of one browser tab, captions and camera included, with no editing pass after. That's the whole practical win, and it's a bigger one than it sounds — the editing step wasn't adding polish, it was cleaning up work the recording step should have gotten right the first time. What stuck with me more is realizing how long I'd tolerated a workflow that was obviously wrong just because each individual piece of it — a whiteboard app, a screen recorder, a caption tool — worked fine on its own. The problem was never any single tool. It was that I'd never questioned why they had to be three tools. The code's on [GitHub](https://github.com/encoreshao/excalidraw-recording) if you want to see how it fits together, or just want a whiteboard that doesn't hand you off to a second app the moment you hit record. --- ## 10 Years of Rails: What I Still Reach For Every Day - URL: https://blog.icmoc.com/en/05-ten-years-rails - Date: 2025-12-10 - Tags: Ruby, Rails - Excerpt: Not a framework review. A personal account of which Rails patterns aged well, which didn't, and what I've built that I'm still proud of. My first Rails app was a disaster. I was 23, I'd just learned the framework from Michael Hartl's tutorial, and I built a social platform for musicians with the confidence of someone who didn't know what they didn't know. The app had N+1 queries on every page. The authentication was homebrew. The deployment was a single Heroku dyno that restarted on every request above 512MB memory. It worked, sort of, for a few months. Then it collapsed under its own weight and I learned what "performance" meant by having to fix it in production. That was ten years ago. I've been writing Rails professionally since then — at startups, at Ekohe, for enterprise clients across retail, logistics, HR, and construction. Here's what actually held up and what I've quietly stopped doing. ## What aged well **Migrations.** I've used database migration tools in a dozen frameworks across my career. Rails migrations are still the best I've encountered. The convention of one change per file, the separation of `up` and `down` (or the `change` shorthand), the timestamps as sequential identifiers — it all just works, and it works reliably at every scale I've shipped at. When I see migration tools in other ecosystems, I'm always comparing them to this. **ActiveRecord callbacks.** Controversial, I know. The community went through a phase of arguing that callbacks are evil and everything should be service objects with explicit orchestration. I tried that for a year. I missed `after_commit`. For lifecycle events that genuinely belong to a model — auditing, cache invalidation, notification triggers — callbacks are exactly the right place. The problem isn't callbacks; it's using them for business logic that belongs somewhere else. **Convention over configuration.** The `rails new` command gives you a working app. The file structure tells you where to put things. New developers to a Rails codebase can find their way around in hours, not days. After spending months in frameworks where configuration is a discipline unto itself, I keep coming back to how productive this convention makes teams. **The asset pipeline (and now Propshaft/Importmap).** The web frontend world changes fast. Rails has had to adapt — asset pipeline to Webpacker to the current Importmap + Propshaft default. The adaptations have been bumpy, but the underlying idea of treating frontend assets as first-class citizens of the app has aged better than "bolt a separate frontend app onto your API." ## What I stopped doing **Service objects everywhere.** This was a specific era of Rails discourse — roughly 2015 to 2020 — where the advice was to extract all business logic into Plain Old Ruby Objects called service objects. `UserRegistrationService`, `OrderFulfillmentService`, `ReportGeneratorService`. Some of these are appropriate. Most of them just moved complexity from one file to another without making it simpler. I'd look at a controller action that called five service objects and realize it was harder to understand than the equivalent code in a fat model would have been. Now I reach for service objects when the alternative would create a model that does too many different things — not as a default pattern for everything. **Decorators over partials.** There's a legitimate use case for the decorator pattern in Rails — Draper was popular for a while. But the overhead of wrapping every object to add presentation logic was rarely worth it for the apps I was building. `helper_method` in controllers and well-organized partials cover most cases without the indirection. **Custom authentication before trying Devise.** I rolled my own authentication for the first two years because I wanted to understand the primitives. That was a valuable exercise once. Now I use Devise or the newer `authentication-zero` generator unless there's a specific reason not to. The primitives haven't changed. My time has more valuable uses. ## Projects I'm still proud of **WorkflowPro**: An office automation system with approval workflows, HR integration, and document management for an elevator manufacturing company. It was a Rails monolith serving 200+ employees with zero downtime for three years. The thing I'm proud of isn't the features — it's the data model. We got the domain model right in the first design meeting, and it didn't require major revision as the requirements grew. **The elevator industry ERP**: Multi-tenant Rails with project management, contract tracking, and financial reporting. I built it in 2019 and it's still in production. The codebase is older than some of the engineers who maintain it now. The fact that it's maintainable — that a new developer can understand it in a week — is the outcome I'm most satisfied with. **An internal search platform at Ekohe**: Elasticsearch integration with a Rails API, doing faceted search over a few million documents. Not glamorous, but it was technically satisfying to get right. The indexing pipeline and the query builder are still among the most carefully designed code I've written. ## What I still use Rails for Complex data-backed applications with multiple user roles, approval workflows, audit trails, and integrations with external systems. E-commerce backends. API backends for mobile apps. Admin interfaces for operations teams. I don't use Rails for: real-time collaborative apps (Phoenix/Elixir is better here), pure serverless functions, or frontend-heavy SPAs where the server is just a data API and you want something lighter than Rails. The framework is 20 years old and still my first choice for a broad class of problems. That's not nostalgia — it's just the right tool. --- ## Building TrendShop: When AI Meets Fashion Discovery - URL: https://blog.icmoc.com/en/06-trendshop - Date: 2025-10-05 - Tags: AI, Product - Excerpt: How we built a social fashion platform powered by AI recommendation — what worked, what the users actually wanted, and the pivot we almost didn't make. The pitch for TrendShop was clean: a social platform where AI surfaces fashion you'll actually buy, based on what you and people like you are engaging with. Think Pinterest's discovery model plus a recommendation engine that learns faster because it has social signals, not just individual behavior. We believed in the idea. We built it. And then we learned the difference between what people say they want and what they do. ## The original architecture The technical foundation was a social data collection pipeline feeding an AI recommendation engine. Users followed each other, saved items, shared outfits. The AI analyzed those signals — engagement patterns, save rates, category affinities, price range behavior — and generated a personalized feed. The backend was Rails, naturally. The recommendation engine started as a collaborative filtering model (similar users bought similar things) and grew to incorporate content-based signals as we accumulated more data. The social graph lived in a relational database; the recommendation models ran separately and wrote recommendations back to a cache the Rails app could query. We built integrations with several fashion retailers' product catalogs via their affiliate APIs. This gave us a real inventory to recommend from, with current pricing and availability. The tech worked well. The app felt good to use. And almost none of our users engaged with it the way we'd designed. ## What they actually did We expected TrendShop to be used like Instagram — daily check-ins, browsing the feed, saving things to wishlist for later. What we found in the analytics: Users arrived with a specific intent. "I need an outfit for a wedding." "I'm looking for work-appropriate summer clothes." "My friend has a style I want to match." They'd use the AI discovery feature heavily for 20-30 minutes, find what they wanted, and then not come back until they had another need. The social features — following, sharing, building a profile — had almost no organic adoption. We'd built a social network nobody asked for. The behavior we were seeing was closer to a search engine with strong recommendations than a social platform. ## The pivot we almost didn't make There's a specific kind of organizational denial that happens when your assumptions turn out to be wrong. The social features were the most technically interesting parts of the product. They were what made us different from a generic fashion search. Abandoning them felt like admitting failure. We spent two months trying to increase social engagement with features we believed would unlock the behavior we wanted. Nothing moved. The honest reckoning: we had built product assumptions into the architecture and we were reluctant to revise them because the architecture was hard to change. The social graph was woven through the data model. Pivoting to a pure discovery and recommendation model would mean significant rework. We did it anyway. We stripped the social layer to a minimal "follow for recommendations" feature (so the collaborative filtering signals still flowed) and rebuilt the UX around intent-based sessions: tell us what you're looking for, and we'll surface the best options across our catalog. Session time dropped. Transaction conversion increased by 40%. ## What the AI actually got right The recommendation engine ended up being most useful not in the feed but in a specific interaction: showing users items that matched the style of something they already liked but hadn't found. We called this "similar style, different price point." A user saves a designer bag. The engine surfaces three alternatives at 30%, 50%, and 70% of the price, all with similar visual characteristics and category signals. For users on a budget, this was genuinely valuable — they'd come in knowing what they wanted stylistically and leave with something they could actually buy. This was the AI doing something a search engine can't: understanding style as a multidimensional space and finding neighbors, not just keyword matches. ## Lessons for AI product builders **The AI feature should solve a problem the user already has, not create a new use pattern.** Our social discovery assumption required users to adopt a new behavior (casual browsing for fashion inspiration). The "similar style" feature solved a problem users brought to us (I know what I want, help me find the affordable version). **Measure the behavior you're changing, not the behavior you're adding.** We measured daily active users and session length, both of which optimized against casual browsing. We should have measured "did the user find something they wanted and buy it?" much earlier. **Data model debt is real.** We paid a significant rework cost for the pivot because the social assumptions were deep in the schema. If you're uncertain about a core product assumption, build that part of the system with the least coupling possible. TrendShop taught me that AI works best when it augments a user's existing intent rather than trying to create new behavior. That's a design principle I've applied to every AI product since. --- ## WorkflowPro: Building Office Automation That Actually Gets Used - URL: https://blog.icmoc.com/en/08-workflowpro - Date: 2025-07-20 - Tags: Rails, Product - Excerpt: How I built an approval workflow system for an elevator manufacturer that ran three years without downtime — and what I learned about getting enterprise software right the first time. Enterprise software has a reputation for being bloated, difficult, and expensive. I've seen why that reputation exists — most of it is built by teams optimizing for contract scope rather than for the people who will actually use it every day. WorkflowPro was a chance to do it differently. ## The problem The client was an elevator manufacturing company with 200+ employees across operations, HR, finance, and project management. Their approval processes — purchase requests, expense reimbursements, time-off requests, equipment procurement, contract reviews — ran entirely on email and printed forms. Things got lost. Approvals took weeks for decisions that should take hours. No one had visibility into where a request was in the process. HR specifically was in pain: they were managing employee documentation, onboarding checklists, policy sign-offs, and department transfers through a combination of shared folders and manual follow-ups. It was held together by institutional knowledge that lived in the heads of two specific people. They'd tried an off-the-shelf workflow product. It was too generic, too expensive, and required a consultant every time they needed to add a new form. After two years of fighting it, they came to us. ## The architecture WorkflowPro is a Rails monolith. I chose monolith deliberately — 200 users across one company does not need microservices, and the operational simplicity of a single deployment paid off many times over the years it was in production. The core data model is built around three concepts: **Workflow templates** define the steps, approvers, and conditions for a process. A purchase request template might say: any amount under 5,000 RMB needs one manager approval; over 5,000 needs manager plus finance director; over 50,000 needs those two plus a VP sign-off. **Workflow instances** are individual requests moving through a template. When an employee submits a purchase request, an instance is created. The system knows which step it's on, who needs to act, and what happens next. **Actions** are the events that advance instances: approvals, rejections, requests for clarification, automatic time-based escalations. This separation turned out to be the most important design decision I made. Workflow templates change occasionally; instances never change their template mid-flight. Keeping them separate meant we could update approval chains without affecting in-progress requests, which is exactly the behavior you need. ## Getting the data model right In the design meeting, I spent two hours pushing back on how the client wanted to model their HR data. They wanted to track employees as rows in a single table with dozens of columns. I wanted to model the relationships explicitly: employees have positions, positions belong to departments, departments have heads. It was the right fight to have. The HR integration — pulling employee data to auto-populate requestor information, auto-route to the correct manager, and handle org-chart changes — only worked cleanly because the relational model reflected reality. Three years later, the schema has had zero breaking changes. New feature requests fit naturally into the existing structure: document versioning, delegation of approval authority, bulk processing, department transfer workflows. They all composed with what was already there. ## The features that mattered I built a lot of things. The features that actually mattered turned out to be a short list. **Email notifications that include the action button.** Approvers don't go to the app — they're in email. Every notification email contains an "Approve" or "Reject" button that authenticates with a one-time token and records the action without requiring the approver to log in. Adoption went from 40% to 90% when we added this. **Delegation.** People take vacations. The ability to delegate your approval authority to a colleague for a date range, with automatic reversion, solved a problem the previous system couldn't handle at all. It turned out to be one of the most-mentioned features in feedback. **A proper audit trail.** Every state change is logged with who did it, when, and any comment they left. Operations teams in manufacturing care deeply about records — they need to be able to reconstruct a decision for compliance purposes. The audit log was a checkbox in the original spec; it became a frequently-used feature. **Escalation timers.** If an approval sits unacted on for X hours, it automatically escalates to the approver's manager. This single feature, which took two days to build, reduced the "stuck in someone's queue" problem to near zero. ## What I got wrong The document management feature was over-engineered. I built a versioned document storage system with check-in/check-out, preview rendering, and a permission matrix that could express fine-grained access controls. Maybe 20% of that was ever used. A simple file attachment with version history would have served 90% of the use cases. I also built a reporting dashboard with 15 different charts. Users looked at two of them: "requests pending my approval" and "how long do approvals take on average by department." I should have shipped those two and seen whether anyone asked for more before building the rest. ## Three years without downtime The thing I'm most proud of isn't a specific feature. It's that the system ran for three years in production, serving 200+ daily active users, without a single unplanned outage. That reliability came from boring decisions: a single Heroku standard dyno with Puma, Postgres as the only data store, Sidekiq for background jobs with Redis, comprehensive test coverage on the state machine transitions, and a staging environment that matched production exactly. The one time we had a problem was a database migration that locked a table for longer than expected during business hours. We had a post-mortem. We added zero-downtime migration practices to our deployment checklist. It never happened again. Enterprise software doesn't need to be complicated. It needs to be reliable, understandable, and designed around how people actually work. WorkflowPro was all three, and that's why it's still running. --- ## ruby-office365: A Ruby Client for Microsoft Graph - URL: https://blog.icmoc.com/en/12-office365-ruby-gem - Date: 2024-12-11 - Tags: Ruby, Open Source, API - Excerpt: Why I built a small Ruby gem for Office 365 instead of using what already existed, and what it actually looks like to pull mail, calendars, and contacts through it. A client asked for something that sounded almost trivial: show their customers' upcoming Outlook meetings inside our product's dashboard. Read their calendar, render it next to the rest of their day. Two-day estimate, I figured, mostly integration glue. Then I went looking for a Ruby gem to talk to Microsoft Graph and found a graveyard. The `o365` gem hadn't been touched in years and threw deprecation warnings on install. `microsoft_graph` was auto-generated from Microsoft's OpenAPI spec — technically complete, but every call meant fighting a client object that mirrored the entire Graph API surface instead of the six endpoints I actually needed. Nothing in between existed. So I wrote `ruby-office365` instead of the calendar feature, and the calendar feature came a week later. ## What it needs to do Microsoft Graph itself isn't hard, it's just wide. One host, one auth scheme, and then dozens of resource paths that all shape their JSON slightly differently — mailboxes nest differently than calendars, events carry `@odata.nextLink` for pagination but subscriptions don't, and every response wraps its payload in an OData envelope you have to peel off before you get to anything useful. I wanted the gem to hide all of that behind three things: a client you configure once, model objects instead of raw hashes, and pagination that just worked without the caller thinking about it. ```ruby client = Office365::REST::Client.new do |config| config.access_token = "YOUR_ACCESS_TOKEN" config.debug = false end ``` Every resource — mailbox, calendars, contacts, events — funnels through one shared method that turns Microsoft's raw response into `{ results: [...], next_link: "..." }`. Fetch a single record by id and you still get a results array with one model in it. Same shape everywhere, whether you're on page one or page six. That consistency is the entire reason the gem exists instead of just hitting the API directly with Faraday. ## What it actually looks like to use Get your own profile: ```ruby response = client.me response.display_name # => "Hello World" response.as_json # => { display_name: "Hello World", mail: nil, ... } ``` Pull calendars and events: ```ruby client.calendars[:results] client.events[:results] client.events[:next_link] # a specific window client.events({ startdatetime: "2024-11-14T00:00:00.000Z", enddatetime: "2024-11-21T00:00:00.000Z" }) # a single event by id — still comes back as a one-item array client.event("identifier")[:results] ``` Contacts and mail work the same way: ```ruby client.contacts[:results][0].display_name client.messages({ filter: "createdDateTime lt 2022-01-01" }) ``` And refreshing an expired token doesn't require a separate library: ```ruby client = Office365::REST::Client.new do |config| config.tenant_id = tenant_id config.client_id = client_id config.client_secret = client_secret config.refresh_token = refresh_token end response = client.refresh_token! response.access_token ``` Later on, when we needed to react to changes instead of polling for them, the gem grew webhook subscriptions too — `client.create_subscription(args)` registers a callback URL with Graph, and `client.renew_subscription(args)` keeps it alive before it expires. Same client, same call shape, one more resource. ## Why it's built this way The design choice that mattered most was routing every single resource through that one `wrap_results` method rather than letting each endpoint parse its own response. Graph's pagination convention — check for `@odata.nextLink`, use it verbatim as the next request URL if present, otherwise build the URL from query params — only had to be written once, in one place, instead of once per resource with the fifth copy inevitably drifting from the first four. ## Problems found and fixed **A dependency on Rails that nothing declared.** The first version built its query strings with `to_query`, a method that only exists because ActiveSupport monkey-patches it onto `Hash`. That's fine inside a Rails app and breaks immediately in a plain Ruby script or a non-Rails service, with an error that just says the method doesn't exist and gives no hint why. The fix was to stop borrowing it — write a small `Hash#ms_hash_to_query` using nothing but `CGI.escape` from the standard library, and drop the implicit dependency entirely. A gem meant to be usable outside of Rails shouldn't quietly require it. **A pagination bug that never threw an error.** The date-range branch of the `events` method — the one that calls Graph's `calendarView` endpoint instead of the plain events endpoint — was written as a near-copy of the plain branch, and it built its own argument hash from scratch instead of merging in whatever the caller had passed. That meant a `next_link` a caller was using to page through results got silently dropped before it ever reached the URL builder. A background job paginating a calendar view would keep re-issuing the same first-page request instead of advancing — no crash, no exception, just the same page of events coming back until the access token expired and the job failed for an unrelated reason. The fix was one line, merging the caller's args instead of building a fresh hash, but it only shipped broken because that branch had skipped the pattern the rest of the gem followed. ## When I'd actually reach for it `ruby-office365` isn't trying to cover the whole Graph API the way `microsoft_graph` does, and I don't think it should. It covers mail, calendars, contacts, events, and subscriptions, because those are the things a product actually asks for when someone says "sync our users' Outlook data." If you need Teams, SharePoint, or OneDrive through Graph, the generated client is the right tool — you want completeness there, not a hand-rolled wrapper guessing at endpoints it's never called. But for the common case — a background job or a dashboard that needs someone's mail, calendar, or contacts and doesn't want to hand-build OData query strings to get them — a client you configure in three lines and call like a normal Ruby object is worth more than API coverage you'll never use. `ruby-office365` is at [rubygems.org/gems/ruby-office365](https://rubygems.org/gems/ruby-office365). --- ## Building a Ruby Wrapper for the Crunchbase API - URL: https://blog.icmoc.com/en/07-crunchbase-ruby - Date: 2024-08-18 - Tags: Ruby, Open Source, API - Excerpt: What I learned wrapping a third-party API in idiomatic Ruby — rate limits, authentication flows, model design, and the V3 to V4 migration I didn't see coming. The Crunchbase API is genuinely useful. It has company profiles, funding round data, acquisition history, leadership teams, and IPO records for most companies you'd care about. When you're building due diligence tooling, market research features, or anything that needs startup data at scale, it's one of the few data sources that actually covers the space. But the raw API isn't pleasant to use. You get flat JSON responses for entities that have complex relationships. You need to know the right permlink format. Pagination has its own conventions. And the authentication model — API key per request, rate limits enforced server-side with little feedback — requires defensive coding that your app shouldn't have to think about. I built `crunchbase-ruby-library` to put a clean Ruby interface on top of all of that. ## What the gem does The gem wraps Crunchbase API v3.1. At its core, it provides: - A client object that handles authentication, timeout configuration, and request lifecycle - Model classes for each entity type: Organization, Person, Product, IPO, Acquisition, FundingRound - A search interface that translates Ruby hash arguments into the API's query parameter format - A relationship-aware `get` method that can fetch an entity with any of its related data in a single call ```ruby client = Crunchbase::Client.new # Search for organizations results = client.search({ name: "Stripe" }, 'organizations') results.total_items # => 12 results.results # => [#, ...] # Fetch a single entity with relationships org = client.get('stripe', 'Organization') org.headquarters # => location object org.funding_rounds # => array of FundingRound objects ``` The configuration is minimal by design: ```ruby Crunchbase::API.key = ENV['CRUNCHBASE_API_KEY'] Crunchbase::API.timeout = 60 Crunchbase::API.debug = false ``` One line to wire it up, and every call in your app just works. ## The design problems Wrapping an external API in Ruby objects sounds straightforward until you hit the specifics. **Relationships are inconsistent.** Crunchbase's entity model has dozens of relationship types, and they're not consistently structured. Some relationships return arrays; some return single objects. Some include summary data inline; some require separate requests. I spent more time reading API documentation and writing tests against the real API than I did writing model code. The relationship list for Organization alone is 20+ entries: ``` primary_image, founders, current_team, investors, owned_by, sub_organizations, headquarters, offices, products, categories, customers, competitors, funding_rounds, acquisitions, ipo... ``` Each one needed its own handling. The approach I settled on was using `method_missing` to delegate unknown method calls to a relationships hash, populated lazily from the API response. This kept the model classes clean while still allowing natural Ruby access patterns: ```ruby org.founders # fetches if not loaded, returns PersonSummary objects org.funding_rounds # fetches if not loaded, returns FundingRound objects ``` **The batch search API is different.** Crunchbase added a batch endpoint that accepts up to 10 requests and returns a mixed array of results. Building a clean Ruby interface for this was tricky — you pass in heterogeneous request types and get back heterogeneous results, including error objects for invalid UUIDs. I ended up creating a `BatchSearch` model that wraps the results array and lets you iterate over successes and filter out errors: ```ruby requests = [ { type: 'Organization', uuid: org_uuid, relationships: ['founders'] }, { type: 'Person', uuid: person_uuid, relationships: [] } ] batch = client.batch_search(requests) batch.results.each do |result| next if result.is_a?(Crunchbase::Model::Error) puts result.name end ``` **Rate limiting.** V3.1 enforces rate limits but doesn't tell you clearly when you're approaching them. You get a 429 response when you hit the ceiling. The gem wraps all requests with retry logic and exponential backoff, but it took a few production incidents to get the backoff timing right. ## The V3 to V4 migration About a year after publishing the gem, Crunchbase announced they were deprecating V3.1 in favor of V4 — a substantially different API with a new authentication model, different entity schemas, and a completely redesigned search interface. I made a decision I'm still not entirely sure about: I kept `crunchbase-ruby-library` as a V3.1 wrapper and built a separate gem — `crunchbase4` — for V4 rather than upgrading in place. The reason: V4 is different enough that a migration would break almost every method signature and model attribute. Any app using the V3 gem would need significant rework, and doing that under a patch version bump felt dishonest. A new gem gave V4 users a clean start and let existing V3 users stay on a stable dependency while they planned their migration. In practice, this meant maintaining two gems, which was more work than I'd hoped. But it gave downstream users predictability, and predictability is the most underrated thing a library author can provide. ## What I'd do differently **More aggressive error types.** The gem raises a single `Crunchbase::Error` class for API failures. A year of production use taught me that callers need to distinguish between "API key invalid" (fatal, alert immediately), "rate limited" (retry), "entity not found" (expected, handle gracefully), and "API down" (circuit-break). A single error class pushes that differentiation onto every caller. A hierarchy of error classes would have been better from day one. **Test against recorded fixtures earlier.** I used `VCR` for recording HTTP interactions, but I added it late. The first tests hit the real API, which was slow, required credentials in CI, and occasionally broke when Crunchbase changed response formats. Recording all API interactions from the start would have made the test suite faster and more reliable. **Version the models explicitly.** Crunchbase changed the shape of several response objects between minor API versions without a major version bump. The gem's models assumed a stable shape and broke silently when fields were added or renamed. Explicit schema versioning, or at minimum defensive attribute access with `dig`, would have caught these breaks sooner. The gem is at [github.com/encoreshao/crunchbase-ruby-library](https://github.com/encoreshao/crunchbase-ruby-library) and still works for V3.1 API access. If you're building against V4, the `crunchbase4` gem at [github.com/ekohe/crunchbase4](https://github.com/ekohe/crunchbase4) is where that work continues. --- ## china_regions: Lessons from a Ruby Gem - URL: https://blog.icmoc.com/en/02-china-regions - Date: 2021-12-02 - Tags: Ruby, Open Source - Excerpt: What I learned shipping a Ruby library for Chinese administrative regions — and why documentation matters more than the code itself. The problem started with a form. We were building a registration flow for a Rails app, and we needed a cascading dropdown for Chinese provinces, cities, and districts. It sounds trivial. It is not. China has 34 province-level divisions, 333 prefecture-level cities, and over 2,800 county-level divisions. The data changes — administrative regions are periodically reorganized by the State Statistics Bureau. Every app that needed this had to solve it from scratch: find a data source, clean it, load it into a database, build the associations, write the UI hooks. I watched three different teams at three different companies do this badly. So I built `china_regions`. ## What the gem actually does At its core, `china_regions` is a Rails engine that provides three database-backed models — `Province`, `City`, and `District` — pre-loaded with official data from the Ministry of Civil Affairs. You add it to your Gemfile, run the generator, run the migration, and your app has a complete, queryable hierarchy of Chinese administrative divisions. ```ruby Province.all # 34 provinces/municipalities/regions City.where(province: Province.find_by(name: "广东省")) # Guangdong's cities District.all.count # 2800+ ``` The generator also copies locale files for Chinese and English names, so the same region object can render as "广东省" or "Guangdong Province" based on your `I18n.locale`. For UI, there's a JavaScript helper that drives cascading selects — you pick a province, the city dropdown populates, you pick a city, the district dropdown populates. It works with standard Rails form helpers. ## The data problem Getting the data right was harder than the code. The official source is the National Bureau of Statistics website, but it publishes data as nested HTML tables, not an API. I wrote a scraper, validated it against the Ministry of Civil Affairs list, and cross-referenced with a second source to catch discrepancies. The data has quirks. Some municipalities (Beijing, Shanghai, Tianjin, Chongqing) are both province-level and contain "districts" that function like cities. The 2-level vs 3-level hierarchy isn't consistent across all regions. I made judgment calls that probably aren't perfect, but they cover 95% of real use cases. The last update I shipped brings the dataset to the 2018 revision — the most recent official publication at the time. Keeping it current is the biggest maintenance burden; the NBS updates the data periodically and I have to re-run the scraper, validate the diff, and publish a new gem version. ## Why it got 25 stars I want to be honest about this: the code is not special. It's a standard Rails engine with modest complexity. The reason people starred it is that the problem is common and the alternatives are worse. When I shipped the gem, the solutions I could find online were either abandoned (outdated data, broken generators), manual (CSV files you had to load yourself), or API-dependent (requiring network calls for region lookups). `china_regions` solved all three problems: maintained data, zero config, local queries. The lesson I took from this is that **solving a boring, real problem well beats solving an interesting problem beautifully**. Cascading region dropdowns for Chinese apps is not exciting. But every Rails developer building a Chinese-market app runs into it. That's a large enough audience to make a gem worthwhile. ## Documentation as the real product Here's what I got wrong in v0.1: the README was three bullet points and a code snippet. I assumed developers would figure it out from the source. They didn't. Issues started coming in: "How do I customize the models?" "The migration failed on PostgreSQL with a column name conflict." "How do I add my own regions?" "What locale keys does this use?" Every one of those questions was answerable in under five minutes if you read the source. But people don't read the source. They read the README, and if the README doesn't answer their question, they open an issue. I rewrote the documentation from scratch — installation, configuration, model usage, form helper examples, troubleshooting for common errors, how to update the data. The issue volume dropped by half. Documentation is the interface between your code and the people who use it. You can have a perfect implementation and zero adoption if the interface is confusing. The README is not an afterthought. For a library, it's the most important thing you ship. ## What I'd change **Smarter data updates.** I'd design a proper update mechanism — a rake task that checks the NBS website for changes and diffs the existing data — rather than the current manual scrape-and-replace approach. **Separate the data from the engine.** The gem bundles data and functionality together. If someone needs to customize the region hierarchy for a specific use case, they're fighting the gem rather than using it. A cleaner design would separate the data (as a data gem) from the Rails integration layer. **Test across more Rails versions from the start.** I've had to do painful retroactive compatibility work as Rails evolved. CI from day one across a matrix of Ruby and Rails versions would have caught these earlier. The gem is at [github.com/encoreshao/china_regions](https://github.com/encoreshao/china_regions). If you're building for the Chinese market with Rails, it'll save you a week of work. --- # 中文文章 (Chinese Articles) ## encore-skills:别再重复教你的 AI - URL: https://blog.icmoc.com/zh/13-encore-skills-gitlab-loop - Date: 2026-07-29 - Tags: AI, DevEx, GitLab, Product - Excerpt: 同一套 GitLab 工作流,我在 Claude Code、Cursor、Codex 里各教了一遍,直到某天一个 MR 漏了 issue 编号——于是干脆做了一个三端共用的技能库。 那是个周五下午,我开着三个终端。Claude Code 对着一个客户的仓库,Cursor 开着另一个项目,Codex 接在第三个项目的 CI 里。三边干的是同一件事:读一个 GitLab issue,找根因,切分支,修复,自查,开 MR。这套流程的说明我写了三遍,三种格式——一份 `CLAUDE.md`,一堆 `.cursor/rules/*.mdc`,一份 `AGENTS.md`——因为每个工具都要求自己那套格式的流程定义。 那周我改了 MR 标题的规范,要求必须带 issue 编号:`fix: #482 nil check on webhook payload`,而不是光写 `fix: nil check on webhook payload`。我更新了 `CLAUDE.md`,却忘了改 Cursor 那份规则。三天后一个 MR 发出去,标题里没有 issue 编号,那个项目的 reviewer 问了一句"这个关联哪个 ticket"。事情不大,但这正是同一套流程散落在三个地方、只有一份是最新的时候会出的问题。 ## 真正动手做的事 解法不是"下次小心点",而是不再维护三份同样的流程。`skills/` 成了唯一的真相来源——每个技能一份 `SKILL.md`,纯 markdown 加 frontmatter,不带任何工具专属语法。然后三个适配器: ```bash ./scripts/setup-claude.sh # 软链接到 ~/.claude/skills/ ./scripts/setup-cursor.sh # 生成 .cursor/rules/*.mdc ./scripts/setup-codex.sh # 生成 AGENTS.md ``` 改一次技能,重新生成适配器,两边一起提交。这个仓库自己也在吃自己的狗粮——它自己的 `CLAUDE.md`、`AGENTS.md`、`.cursor/rules/` 都是从自己的 `skills/` 目录生成的,所以不管我用哪个工具在维护这个技能库,用的都是它自己产出的技能。 由此拆出十个技能,分两条循环。 ## 两条循环 第一条给 PM 和设计师用,全程不需要碰代码库: ``` write-issue → share → gather-feedback → refine → validate → finalize ↑ | └───────── iterate ────┘ ``` `write-issue` 把一个粗糙的想法整理成结构化的 GitLab issue。剩下的交给 `pm-workflow`——发出去、收集用户和相关方的反馈、修改、验证,没到位就回头再迭代一轮。真正的完成标准是"开发者接手不用反问一句就能开工",不是"issue 有标题有描述"。 第二条就是那个让我踩了 MR 标题坑的循环: ``` write-issue → analyze-issue → fix-issue → review-code → create-mr → [merge] ↑ | └──────────────────────── new issue from feedback ────────────────┘ ``` `eng-workflow` 把这条循环跑成一个连贯的会话,一个阶段一个阶段来,每个阶段都有一个必须先满足的门槛:写代码之前先找到根因,收工之前先确认问题真的没了(不只是测试变绿),`create-mr` 跑之前先给出"可以合并"的结论且没有遗留阻塞项,MR 开出去之前先确认目标分支是你真正切出来的那条。`summarize-issue` 和 `triage-issue` 不在主循环上,而是挂在旁边——一个在 MR 已经存在之后发一份复盘,另一个负责回复一个已经在跑的 issue 上的评论。 ## 安装 技能都放在一个仓库里,靠一个安装脚本按工具适配: ```bash # Claude Code —— CLI、桌面版、IDE 插件通用 curl -fsSL https://raw.githubusercontent.com/encoreshao/encore-skills/main/scripts/setup.sh | bash -s -- --claude # Cursor —— 在你想装的项目目录里跑 cd /your/project curl -fsSL https://raw.githubusercontent.com/encoreshao/encore-skills/main/scripts/setup.sh | bash -s -- --cursor # Codex —— 同样在项目目录里跑,生成 AGENTS.md cd /your/project curl -fsSL https://raw.githubusercontent.com/encoreshao/encore-skills/main/scripts/setup.sh | bash -s -- --codex ``` 不管装给哪个工具,这几条一行命令都会自己把仓库克隆(或更新)到 `~/.encore-skills`,不用单独跑一遍 `git clone`。以后想升级,原样再跑一次同样的命令就行。 ## 配一次 GitLab,一劳永逸 所有跟 GitLab 打交道的技能读的都是同一份配置,所以这件事一台机器只用做一次。装完重启 Claude Code 之后: ``` /gitlab-config ``` 它会引导你填入 GitLab 实例地址和一个带 `api` 权限的个人访问令牌。在 Cursor 或 Codex 里没有斜杠命令这一层,直接用大白话说就行——"跑一下 gitlab-config 技能,帮我配置 GitLab 访问"。多个 GitLab 实例(公司自建一个,side project 用 gitlab.com 一个)可以在同一份配置文件里用不同名字并存,每个技能会根据你所在的项目自动选对。 ## 怎么用 日常用起来更像是"说你手头有什么",而不是"运行一个工具"。进到一个有未处理 issue 的项目,直接说"分析一下这个 issue",或者 `/eng-workflow #482`——技能会自己从上下文判断你是从头开始,还是接着一半的循环往下走。同一句话在 Claude Code、Cursor、Codex 里都能用,因为三个工具底层读的是同一份技能定义文件,不一样的只是外面那层读它的壳。 ## 最费时间的那部分 `create-mr` 最简单的版本会默认所有分支都指向 `main`。这在团队从 `staging` 或 `develop` 切分支时立刻就崩了,这种情况在客户项目里常见到我在吃自己狗粮的第一周就撞上了。这个修复牵涉两个技能,不是一个:`fix-issue` 在切出 `/-` 之前先记下当时所在的分支,`create-mr` 再把这个信息读回来,把 MR 指向那个分支,而不是瞎猜 `main`。这个问题单靠哪个技能自己都解决不了——信息必须在两者的交接之间活下来,这跟任何一个技能自身的逻辑都是两码事。 另一个我低估了的地方是记忆。`analyze-issue` 和 `triage-issue` 都需要知道一个 GitLab 项目的一些信息——谁在这个项目里、一个 issue 的评论历史长什么样、上次分析类似问题时我得出了什么结论。没有缓存的话,每次运行都要重新拉取、重新推导一遍,不仅慢,更糟的是一小时前那条评论线索,不会自动进到我现在正在起草的回复里。于是 `gitlab-config` 长出了一个本地缓存,放在 `~/.gitlab/cache/` 下,按实例、group、项目、issue 分层。`sync-issue` 命令依然每次都会调用 GitLab API——新评论不会漏掉——但会按 note id 合并到已有缓存上,而不是丢掉历史重来一遍。像 `acme/rocket` 这种 group 下挂着 `rocket-web` 和 `rocket-mobile` 两个项目,团队名单只同步一次,两个项目共用,不用各拉一遍。 ## 亲眼看它跑起来的那一刻 真正让我确信这套东西能用的,不是一次演示,而是拿 `triage-issue` 跑一个有十一条评论的真实 issue,大部分评论根本不是冲我来的。这个技能准确挑出了两条 @我 的评论,略过了另外两个工程师之间跟我无关的来回讨论,而且草拟的回复是扎根在代码当前实际状态上的——不是猜的,是真的去读了评论里提到的那个文件。我本来预计要花二十分钟修改草稿,结果只改了两分钟,大部分时间还在调一句话的语气。 另一件事更小,但印象更深。`create-mr` 翻到 issue 的评论线索,找到三周前一条评论,链接着一个已经解决了一半问题的相关 MR,于是在描述里加了一段"Related",把这条线索指给 reviewer 看。我没让它去找这个。它是因为技能说要读评论线索才读的,顺手发现了一个我自己都得靠回忆才能想起来的东西。 ## 真正变了的东西 一开始我以为难点是让 AI agent 写出正确的代码、开出干净的 MR。事实证明这部分反而是容易的——三个工具都完全能照着一份写得好的流程走下去。真正的问题是,我从来没把这套流程写成一种能在工具之间原样搬运的形式。"怎么开 MR"、"什么时候该回复评论"这些规则的每一次调整,都只存在我脑子里,当天用哪个工具就敲进哪个工具,不出一周就跟另外两边对不上了。 一份 `SKILL.md` 逼着你把流程写得足够明确才能搬得动,搬得动才能做到改一次、处处生效。这比"AI 帮你写代码"要小得多,但也是拿去客户项目上真跑了一个月之后,唯一站得住的那个想法。`encore-skills` 在 GitHub 上:[github.com/encoreshao/encore-skills](https://github.com/encoreshao/encore-skills),MIT 协议,带安装脚本。 --- ## 十余年,为风投构建一套情报平台 - URL: https://blog.icmoc.com/zh/11-vc-intelligence-platform - Date: 2026-06-24 - Tags: Rails, Product, AI, Infrastructure, VC - Excerpt: 做一套风投情报平台真正需要什么——数据质量、三十多个集成、以及让系统在凌晨三点还能跑的那些无聊基础设施。 第一次会议 briefing 功能完整跑通,是一个周二早上六点五十八分。办公室里还没人,一条 Slack 消息就出现在频道里了。点进去:公司名、最新一轮融资、过去两年的员工增长、创始团队和投资团队之间的 LinkedIn 关系链、十四个月前某位同事在 Salesforce 记的一条通话记录。没有人手工整理过这些,也没有人开口要求过。系统定时触发,从六个不同的数据源各自拉了数据,打包成文档,自动发出去。 这个功能背后的每一块,我分开建了两年。Calendar 集成、关系图谱、公司数据管道、Slack bot,各自独立存在了很久。把它们接在一起,让整条链路第一次完整跑通,那个早上记得很清楚。 ## 我们是怎么介入的 我们以技术合伙人的身份进场。客户是一家风投基金,核心问题不是信号太少,而是数据太分散、流程太碎——CRM 和邮件不互通,LinkedIn 和 Crunchbase 分开看,创始人通话的记录可能在某个人的笔记本里。 我们的工作是理顺这些流程,提升数据质量,并在合适的地方引入 AI。这件事最终做了十多年。 ## 我们构建了什么 一句话版本:一套为风投基金设计的公司情报和关系管理平台。 长版本:一个共享的 Rails 引擎在后台持续运行,追踪数以千计的公司,从三十多个数据源拉数据,给投资团队拼出每家公司的完整画像。它驱动六个应用——主 Web 界面、独立 API、公司入库 pipeline、pipeline review 工具、数据仓库和一个 OAuth 服务。所有应用共用同一套数据层。 每天用它的分析师不需要感知这些。这正是它应该有的样子。 ## 公司身份问题 最难设计的不是某一个具体的集成,而是一个更根本的问题:每个数据源对同一家公司的描述都不一样。 LinkedIn 说这家公司有 250 人,Crunchbase 说 180,ZoomInfo 说 310。SimilarWeb 显示网站流量在下滑,Apptopia 显示 App 下载量在涨。说的是同一家公司,数字对不上。 我设计了一套叫做 Entity–Profile–Site 的三角关系来处理这个问题。Entity 是公司的规范记录——唯一一行,唯一一个域名,唯一的事实来源。Profile 是某个 Site(数据源)对这家公司的描述视角。Crunchbase 有它自己的 profile,LinkedIn 有它自己的,每个都存储来自该数据源的原始 JSONB 数据,不做修改。 每天一次,或者每当某个 profile 更新时,一个派生数据 pipeline 跑起来——读取该公司所有 profile,按数据源权重融合,把时序数据混合处理,最终写入 Entity 的 `derived_data` 字段,供下游功能查询。 把这套聚合逻辑做对,花了好几年。数据库现在超过 800 次迁移,约 300 张表。我在 JSONB 字段上建了基于表达式的 GIN 索引,分析师才能在那些深层嵌套的指标上做筛选,不用等好几秒才出结果。 ## 后台 Worker 的规模 数据不会自己跑来。我们要去取。 生产环境里同时跑着 27 个以上的 Sidekiq 进程,每个负责特定的队列。每个数据源有自己的 job namespace:`Crunchbase::*`、`LinkedIn::*`、`Salesforce::*`、`Office365::*`、`Notion::*`,`app/jobs/` 目录下超过 50 个 namespace。 每个数据源走同一套生命周期:定时任务触发一个 discovery job,job 调用该数据源的 API client,原始数据落进 Profile,系统判断这个 profile 是否已经属于某个 Entity(如果没有,就做模糊匹配),派生数据重算,feature 和 score 更新。 让这套流程在规模下稳定运行的,是那些不会出现在 changelog 里的东西:限速时自动退让重试,Redis 单线程锁防止并发 job 踩踏同一个 entity,去重 key 阻止同一个目标在一天内被重复处理。这些年沉淀成了一套 concern 组合——job 去重、限速、单线程执行、API 凭证轮换——job 通过 include 合适的模块来获得对应行为。 没人会把这些写进 release note,但它们是系统能在凌晨三点无人值守的原因。 ## 人的问题 做决定的不是公司,是人。 平台追踪具体的个人——创始人、高管、投资人——记录职业经历、LinkedIn 关系、邮件往来,以及和投资团队的互动历史。这里的身份识别问题真的很难。LinkedIn 上的"John Smith, CEO at Acme Corp"可能和 Salesforce 里的"Jonathan Smith"、Office365 联系人里的"J. Smith"是同一个人。我做了四种合并策略:按邮箱合并、按昵称和别名合并、按跨邮箱匹配合并、按姓名模糊相似度合并。 当它奏效的时候,投资团队能看到某位同事两年前和这位创始人喝过咖啡,另一位同事和 CTO 是 LinkedIn 一度好友。这就是冷邮件和有效引荐之间的区别。 ## 最让我头疼的集成 三十多个集成里,Salesforce 是唯一一个我可以单独写一篇文章的。 双向同步在设计文档里看起来很清晰。实际情况是:公司 entity 推送到 Salesforce account,Salesforce 联系人被拉回来变成人的 profile,任务完成状态双向同步,account 归属变更在两个系统之间传播——与此同时,用户还在两边同时编辑记录。 我做了一个"dispatch center"模式来协调同步操作,避免触发无限更新循环。七年了,它还是会跑出新的 edge case。我已经跟这件事和解了。 ## AI 进场 平台大部分时间里,"情报"是结构化的——融资信号、员工增长、流量趋势、评分变化。2022 年前后,我们开始往里加 LLM,"有用"这件事的定义也跟着变了。 开篇提到的会议 briefing 功能,是这套思路最直观的体现。会议前一晚,系统拉取日历事件,识别关联的公司或人,把平台掌握的所有信息——融资历史、员工增长、最新动态、关系链路——交给 LLM 做综合,在团队进门前把简报发进 Slack。没有人调度这件事,也没有人写这些内容。AI 负责语言,结构化数据负责事实。 这成了我们此后每一个 AI 功能的设计模式:模型处理语言,pipeline 保证真实性。 **Chatbot 问答。** 分析师可以用自然语言问任何在库公司的问题——"他们工程团队过去一年的招聘趋势怎样?""我们团队有谁认识这家公司的人?"——答案来自实时结构化数据,不是静态文档。交互是对话式的,内容是有来源的。 **AI 邮件起草。** 投资团队想联系某位创始人时,系统会基于关系上下文起草开场白:共同认识的人、过去的互动记录、公司最近的里程碑事件。分析师编辑后发出去。AI 解决第一稿,人专注于真正的关系。 **LLM 叙述摘要。** 每个公司 profile 现在都有一段 AI 生成的文字,把结构化数据综合成可读的段落——这家公司做什么、增长态势如何、现在有什么值得关注的地方。它和原始指标并排呈现,不是替代品。 **基于 pgvector 的 RAG 检索。** 投资备忘、通话记录、Notion 文档全部向量化存储。用户提问时,系统先检索语义最相关的片段,再生成回答——答案基于基金自己的文档,而不是模型的通用知识。 **评分模型。** 增长速度、团队背景、主题契合度、关系亲疏——多维度信号汇聚成复合评分,给 pipeline 里的公司排序。这不是替代判断,而是改变什么先被看到。一个分析师一周要过两百家 inbound,不可能给每一家同等的注意力。评分模型把值得多看一眼的那些推到前面。 **投资主题分类。** 一个训练好的分类器读取公司 profile,预测它最符合基金哪个投资方向。用于 inbound 筛选,也用于在已有数据库里发现被忽视的标的,还给团队提供了一套跨 portfolio 做模式匹配的共同语言。 这些没有替代结构化数据 pipeline,它们叠在上面。结构化数据给 AI 准确的锚点,AI 让结构化数据变得可读、可操作。底层的 entity 记录如果是脏的,就没办法写出有用的公司摘要。AI 功能加得越多,越感谢当年花在清洗和规范化数据上的那些年,而不是少了它们。 ## 十多年教给我的事 这一路最让我意外的:数据质量是产品问题,不只是工程问题。最难搞的 bug 不在代码里,在数据里——改了名字的公司,被悄悄收购的域名,两年换了三次工作的人。系统需要对"这是谁"有自己的判断,而这些判断需要不断被质疑和更新。再好的代码也弥补不了一条脏的规范记录。 那些枯燥的基础设施比我预期的更重要。Job 去重、Redis 锁、限速、幂等写入——不会让 changelog 好看,但它们是系统为什么可靠的原因。 还有一件事:跟一个投资团队深度合作这么多年,改变了我对技术合作本身的理解。分析师不用数据模型思考,他们用关系、模式识别、和创始人对话的感觉来思考。做出一个他们真正会用的东西——而不是技术上正确但悄悄被忽视的东西——需要始终贴近真实的工作方式,而不只是需求文档。 这里的技术复杂度是真实的。但更难的判断是:哪些复杂度值得现在建,哪些可以等。这种判断只有在一个问题里待得足够久、不再被它轻易惊到之后,才会真正长出来。 --- ## 用 Vagrant 搭建 PostgreSQL 10 主从复制 - URL: https://blog.icmoc.com/zh/10-postgres-replication - Date: 2026-06-19 - Tags: PostgreSQL, Infrastructure, DevEx - Excerpt: 团队只有一台数据库,没有备份节点。我用两台虚拟机把流复制从头搭了一遍,搞清楚每一步为什么这么做。 团队的数据库只有一台。没有备份节点,没有容灾,就一台机器扛着所有读写。大家都知道这不对,但也都在假装没这回事。 说起来也没人在骗谁。备份是有的,方案也是有的。方案就是"服务器挂了就从备份恢复"——这意味着宕机时间是小时级别,不是分钟级别。这种方案听起来合理,直到凌晨两点真的要执行它。 我想在真正需要用到之前,先把主从复制搞清楚。于是用 Vagrant 起了两台 VirtualBox 虚拟机,Ubuntu 18.04,PostgreSQL 10,花了一个周末。 ## 先说环境 两台机器,两个 IP: - 主库(Master):`192.168.33.11`,负责读写 - 从库(Slave):`192.168.33.22`,只读备用节点,跟着主库的 WAL 流走 Vagrant 的好处是随时可以删掉重来。那个周末我重来了不止一次。 ## 主库的配置 主库上的每一次写操作——insert、update、delete——在真正落到数据文件之前,都会先写进 WAL。这个机制本来是为崩溃恢复设计的,但它同时也是从库订阅的数据流。你在主库要做的,就是把这个流暴露出来,给从库连接的权限。 先建一个复制用的角色: ```sql CREATE ROLE replicate WITH REPLICATION LOGIN PASSWORD 'your-password'; ``` 然后改 `/etc/postgresql/10/main/postgresql.conf`: ```conf listen_addresses = '*' wal_level = replica max_wal_senders = 3 wal_keep_segments = 64 ``` `wal_level = replica` 是关键。没有这个,Postgres 不会生成流复制需要的 WAL 数据,后面全白做。`wal_keep_segments` 控制保留多少 WAL 段,给从库追进度留的缓冲。 还有一个文件容易被忽略:`pg_hba.conf`。要显式允许从库连进来做复制: ```conf host replication replicate 192.168.33.22/32 md5 ``` 注意这里的数据库名写的是 `replication`,不是 `all`,也不是你的业务库名。这是 Postgres 专门给复制连接用的关键字。写错了,从库连接直接报认证失败,错误信息几乎不告诉你原因在哪。 两个文件改完,重启主库的 Postgres。 ## 从库的配置 大多数教程到这里开始糊弄人,说"配置从库"好像两步就完事。麻烦其实在这里。 先把从库的 Postgres 停掉: ```bash sudo service postgresql stop ``` 然后把数据目录清空: ```bash rm -rf /var/lib/postgresql/10/main/* ``` 不用担心,这是 Postgres 安装时自动生成的空目录,里面没有你的业务数据。你要腾出空间,让接下来的 base backup 把主库的数据完整复制过来。 清完之后把权限补回去: ```bash chmod 700 /var/lib/postgresql/10/main chown postgres:postgres /var/lib/postgresql/10/main ``` 然后以 postgres 用户身份跑 `pg_basebackup`: ```bash sudo -u postgres pg_basebackup \ -h 192.168.33.11 \ -D /var/lib/postgresql/10/main/ \ -P -U replicate \ --wal-method=stream ``` 这一步把主库的整个数据目录复制到从库,同时记录下备份时主库的 WAL 位置。从库之后就从这个位置开始订阅 WAL 流。`-P` 显示进度。新装的 Postgres 跑起来几秒钟,生产环境数据多就得等更长。 备份完成后,在从库数据目录里创建 `recovery.conf`: ```conf standby_mode = 'on' primary_conninfo = 'host=192.168.33.11 port=5432 user=replicate password=your-password' trigger_file = '/tmp/MasterNow' ``` `standby_mode = 'on'` 告诉 Postgres 启动后继续待在 recovery 模式,持续跟随主库的 WAL 流,而不是完成 recovery 之后变成一个独立的主库。 `trigger_file` 是 failover 的开关。在从库上 `touch /tmp/MasterNow`,从库就停止跟随主库,把自己提升为主库。这是最原始的切换方式。 启动从库的 Postgres: ```bash sudo service postgresql start ``` ## 确认复制正常 先确认连接。在主库查: ```sql SELECT * FROM pg_stat_activity WHERE usename = 'replicate'; ``` 能看到一行,说明从库连上来了,WAL 流已经在跑。一行都没有,说明从库根本没连进来——回去看 `pg_hba.conf`。我第一次就是那里写错了,从库报的认证失败几乎没给出任何有用的信息。 连接确认没问题之后,真正的验证是一次写操作。在主库建一个数据库: ```sql CREATE DATABASE replication_test; ``` 然后去从库查: ```sql SELECT datname FROM pg_database WHERE datname = 'replication_test'; ``` 不到一秒就出来了。之前配的所有东西——WAL 级别、复制角色、base backup、recovery.conf——最终就是这一行出现在另一台机器上。那一刻整个逻辑才真的串起来,不再是抽象的概念。 如果没出来,Postgres 日志会告诉你哪一步断了,通常很具体。 ## 做完之后想清楚的事 让我意外的是,整个流程对顺序的依赖比我想的强——不只是步骤要对,还要顺序对、权限对、用哪个用户跑对。每一步出错都不会大声报错,它只是悄悄失败,然后下一步继续跑,好像什么都没发生。 让我低估的是 `recovery.conf`。它不是普通的配置文件,它是模式选择开关。Postgres 启动时读它,决定自己是备库还是主库。有意删掉它来做 failover 提升:对,这就是设计行为。无意中删掉:你现在有两个独立的主库在向同一个应用写数据。文件名完全看不出它承载了多重的语义。 trigger file 让我想明白了另一件事。生产环境没人直接用这个切换——Patroni 和 repmgr 都自动处理 failover。但那些工具是建在这个原语上面的,理解了这个,我才能真正读懂它们的文档,而不只是照着操作。 这套 Vagrant 环境在跑通之后一周就删掉了。后来真的跟基础设施团队谈生产高可用的时候,我不需要别人给我解释什么是 WAL 段,什么是 `max_wal_senders`。这个框架已经在脑子里了。那个周末花得值。 --- ## github-trending:从一个定时脚本到 React 应用 - URL: https://blog.icmoc.com/zh/09-github-trending - Date: 2026-06-13 - Tags: Open Source, API, React, DevEx - Excerpt: 我想每周追踪 GitHub 上快速上升的项目,不想靠手动刷页面。一个 Node.js 脚本最后变成了一个完整的 React 应用——记录这个过程。 每周一早上,我会打开 GitHub 的 Trending 页面,快速扫一眼,试图分辨哪些是新上来的,哪些只是老面孔。但那个页面把今天、本周、本月的内容混在一起,没有筛选,没有导出,也没有跨时间对比的方法。这样记了几个月,我开始不相信自己的记忆了,于是开始把数据写下来。 最初的版本是十几行 JavaScript。 ```js const response = await axios.get('https://api.github.com/search/repositories', { headers: { Authorization: `token ${GITHUB_TOKEN}` }, params: { q: 'created:>=' + getLastWeekDate(), sort: 'stars', order: 'desc', per_page: 20, }, }); ``` 核心就是这段。GitHub Search API 支持 `created:>=` 限定符,可以查出过去七天内创建的仓库,按 star 数倒序排列。这不完全等同于 GitHub 自己定义的"trending",但足够用——一个仓库在上线第一周就涨到几百 star,就是值得关注的项目。 脚本把结果存到 `docs/YYYY/MM/date.json`,同时生成一份 CSV,然后我设了个 cron job,每周日晚上自动跑。 ```bash 0 21 * * 0 cd /path/to/github-trending && node index.js ``` 这套流程跑了大概三个月。本地积累了一堆 JSON 文件,我用 VS Code 打开搜索。不好用。想比较一月和三月的趋势,就得手动 diff 文件。 ## UI 变成了必需品 数据采集的问题解决了,怎么看数据还没解决。 我做 React 应用,主要是需要两个功能:一个可以排序的数据表格,以及不用写 grep 命令就能按语言或 topic 过滤。Ant Design 5 把大部分表格工作都包了,`Table`、`Input`、`Select` 这些标准组件直接用就行。真正有意思的 UI 问题是双视图模式。 开发者和研究者用这份数据的方式不一样。开发者想快速扫描、找到感兴趣的项目,需要卡片视图——仓库名、描述、star 数,一次看一个,点进去看详情。研究者或分析师想同时对比大量仓库,需要紧凑的表格,把二十多个字段都铺出来。我把两种视图都做了,底层用同一份数据,用户自己切换。 ``` 卡片视图 → 视觉浏览,一个仓库一张卡 表格视图 → 数据分析,所有字段可见,支持排序和筛选 ``` 可以展示的字段涵盖 GitHub API 返回的完整信息:仓库名、所有者、头像、描述、topics、许可证、各种 URL 变体、创建/更新/push 时间、star 数、fork 数、open issues、代码体积、主要语言,二十多个属性。大多数人用其中六个左右。 ## API 限频的问题 GitHub Search API 未认证的情况下每小时只有 10 次请求。用 Personal Access Token 并开放 `public_repo` 权限,可以提升到每小时 30 次,但在分页翻数据的场景下还是不够用。 最初的做法是页面加载时直接全量拉取。结果当天就被同事的频繁访问触发了限频。修复分两步:在客户端用 `localStorage` 做好缓存,以及在触发限频时明确给出提示,而不是静默返回空结果。 Settings 面板把 token、展示字段的选择、每页条数都存在 `localStorage` 里,不走服务端。token 不离开浏览器。这是有意为之的设计——我不想为了代理 GitHub API 再维护一个后端,也不想让用户把 token 交给我的服务器去保管。 ## 真正花时间的地方 查询的设计改了好几版。 最初用的是 GitHub 自己的 trending 接口。但那个接口不在官方 API 里,是直接抓页面的——接口路径会不定期变动,访问频率稍高就会被屏蔽。我早期版本就翻车过,GitHub 悄悄改了页面结构,脚本直接挂掉了。 改成 Search API 加 `created:>=YYYY-MM-DD` 之后稳定多了,但衡量的东西变了。一个两年前创建、最近突然火起来的仓库不会出现在结果里。这个取舍我考虑了一下,接受了。这个工具的目的是发现新东西——新冒出来快速上升的项目——不是监控已有仓库的突发热度。 导出格式比我预期的有用。我以为 JSON 够用了。后来 PM 要表格。用 `papaparse` 做 CSV 导出花了一个下午,之后每个月少接四个"能帮我把这个导出来吗"的消息。 ## 现在的状态 Web 应用跑在 [github.ranbot.online](https://github.ranbot.online)。cron 脚本每周日还在跑。`docs/` 目录里已经积累了近两年的周快照,我一直打算把这些数据做成更有意思的东西——趋势曲线、语言分布的变化之类——但一直没排上。 这个项目起点不是一个清晰的需求,而是我对一个重复手动操作的厌烦。那往往是最值得做的工具的起点。 --- ## 开发 bamboohr-mcp:让 AI 直接对话 HR 系统 - URL: https://blog.icmoc.com/zh/01-bamboohr-mcp - Date: 2026-06-01 - Tags: AI, MCP - Excerpt: 我想让任何 LLM 都能直接调用 BambooHR 的 API。三天后,一个生产可用的 MCP 服务器诞生了。记录整个过程,以及那些出乎意料的收获。 每逢周五下午,HR 团队总会来找我。"能帮我拉一下各部门的人数吗?""下周谁请假了?""工程团队四月份共记录了多少工时?" 这些需求本身并不过分。数据都在 BambooHR 里,问题在于把它取出来的过程——登录系统、找到对应报表、设置日期筛选条件、导出 CSV,有时还要再做一番表格运算。不难,但繁琐。而且每次都绕回我这里,因为只有我熟悉整套操作。 我一直在关注 Model Context Protocol 生态的发展,Anthropic 发布这个协议规范以来,已经出现了不少有意思的项目。MCP 的理念很优雅:你写一个暴露"工具"的服务器,任何兼容 MCP 的客户端——Claude、Cursor、Windsurf 等等——都能自动发现并用自然语言调用这些工具。我当时就想:如果 HR 经理能直接问 Claude "本周谁在休假?",Claude 自己去拿答案,那会怎样? 这就是 `bamboohr-mcp` 的由来。 ## 先搞清楚要解决什么问题 动手写代码之前,我需要想清楚这个服务器到底应该做什么。BambooHR 的 API 有 80 多个端点——员工名册、考勤、薪酬福利、绩效考核、招聘流程、组织架构、自定义报表,应有尽有。我完全可以把所有端点都包进来。 但这是个陷阱。 问题不是"BambooHR 能做什么",而是"我们的团队实际上会问什么"。我花了一个下午,把过去三个月 Slack 里所有 HR 相关的问题整理归类。结论很清晰: - 谁今天请假了 / 谁在上班 - 项目和任务信息(填工时用) - 员工名册查询 - 工时提交和审核 - 基本组织架构 这大约只覆盖了 20% 的 API 功能,却能满足 90% 的真实需求。我决定只做 12 个工具,不再往里添。 ## 开发过程 我选择了 TypeScript,而不是 Python——BambooHR 的 API 是标准的 REST + JSON,Node 的 HTTP 生态配套非常完善。Anthropic 提供的 MCP SDK 让工具定义变得很直接。 每个工具的结构大致相同: ```typescript { name: "fetch_employee_directory", description: "获取所有在职员工的姓名、邮箱、职位和部门", inputSchema: { type: "object", properties: { status: { type: "string", enum: ["Active", "Inactive"], description: "按就职状态筛选" } } } } ``` 具体实现就是对 BambooHR REST 接口做一层类型化的 fetch 封装,加上错误处理和响应归一化。架构层面最麻烦的是认证——BambooHR 用的是 API Key 认证,URL 里还需要带公司域名前缀。我通过环境变量来配置,这样不同的部署环境不需要改代码: ```typescript const token = process.env.BAMBOOHR_TOKEN!; const companyDomain = process.env.BAMBOOHR_COMPANY_DOMAIN!; const employeeID = process.env.BAMBOOHR_EMPLOYEE_ID!; ``` 最终发布的 12 个工具: 1. `fetch_employee_directory` — 员工基本信息列表 2. `fetch_whos_out` — 当前和即将到来的休假情况 3. `fetch_projects` — 考勤用的项目和任务列表 4. `fetch_time_entries` — 指定时段的工时记录 5. `submit_work_hours` — 提交项目工时 6. `get_me` — 当前用户信息 7. `fetch_timesheet` — 每日考勤视图 8. `fetch_departments` — 组织架构 9. `fetch_job_titles` — 职位列表 10. `fetch_benefits_summary` — 福利概览 11. `fetch_pto_balance` — 剩余假期余额 12. `fetch_holidays` — 公司法定节假日 ## 第一版踩的坑 第一版没有处理分页。BambooHR 的员工列表是分页返回的,我只静默地返回了第一页。30 人的团队没问题,300 人的团队数据就会不完整,而且没有任何提示。后来写了一个通用的 `fetchAllPages` 工具方法,跟随 `Link` 响应头自动翻页,才解决这个问题。 我还低估了响应格式对 LLM 消费的重要性。BambooHR 原始 API 返回的是深层嵌套的对象,字段名也不太一致。LLM 能解析,但如果把数据规范化成扁平的、字段名清晰的对象,效果会好很多。最终为每个工具的响应单独写了一层轻量级的转换逻辑。 ## 真正的惊喜 部署到内部环境三天后,HR 经理开始用了。不是通过开发人员——她用配置文件的方式把 Claude Desktop 直接连上了这个 MCP 服务器,开始直接提问。 她第一个真正的查询:"上周哪些工程师没提交工时?" Claude 用一个日期范围调用了 `fetch_time_entries`,又拉了员工名册,做了集合差运算,给出了一份名单。过去要花我十分钟的查询,她三十秒就搞定了。 这才是我真正理解 MCP 的瞬间。它不是一个 AI 功能,它是一个集成层——让你现有的系统对任何 LLM 都变得可访问,而不需要为每个使用场景单独搭建 UI。 ## 如果重来一次 **限流。** BambooHR 有访问频率限制,一个提问方式不当的问题可能导致 Claude 快速连发大量工具调用。应该在服务器端加一个简单的令牌桶限流。 **更清晰的报错信息。** 认证失败时,目前的报错是 "401 Unauthorized"——调试 Claude 连接问题时毫无帮助。应该把 BambooHR 的原始错误响应内容透传出来。 **版本化策略。** 我完全没考虑 API 版本问题。一旦 BambooHR 废弃某个接口,服务器就会静默出错。应该加一个健康检查工具,验证所有接口都还能正常访问。 代码在 [github.com/encoreshao/bamboohr-mcp](https://github.com/encoreshao/bamboohr-mcp),如果你想自己跑起来,或者想基于它对接其他 HRIS,这个模式很容易迁移——换掉 BambooHR 的部分,接上任何带 Token 认证的 REST API,一个下午就能搭出 MCP 服务器的基本骨架。 --- ## AI 书签仪表盘:一个 Chrome 扩展的开发故事 - URL: https://blog.icmoc.com/zh/03-bookmark-dashboard - Date: 2026-04-15 - Tags: AI, Chrome - Excerpt: 用真正有用的东西替换新标签页——我如何开发了一个 AI 驱动的书签仪表盘,以及我下次会怎么做。 我有 800 个书签,大部分都是废的。但里面藏着一些当时带着真实意图保存的链接——想读的文档、想试的工具、打算回来看的文章。每次打开新标签页看到 Chrome 默认页,我都会隐约想起那 800 个链接的存在,然后继续什么都不做。 书签仪表盘就是在这种挫败感里开始的。六个月后,它成了一个真正上架 Chrome 商店的扩展,开发过程让我对浏览器扩展开发的现状有了远超预期的认识。 ## 想法 核心思路很简单:用一个功能完整的书签管理器替换新标签页。所有书签,打开即用。搜索、标签、文件夹、置顶——全都在一个界面里,不需要打开独立的应用或者翻书签菜单。 AI 层是后来加进去的。一旦在单个 JavaScript 上下文里拿到了用户的整个书签库,AI 就开始真正有用起来: - **重复检测**:找出 URL 相同或标题高度相似的书签 - **死链清理**:检查哪些 URL 返回 404 或已经失效 - **标签建议**:分析页面内容并推荐相关标签 - **一键整理**:根据内容分析提出更合理的文件夹结构 这里的"AI 驱动"是实实在在的——AI 做的是手动处理会很枯燥的真实工作,而不仅仅是挂一个聊天框的名字。 ## 技术决策 Chrome 扩展有一套特别的编程模型,三个互相隔离的执行上下文: - **后台 Service Worker**:持久运行(或按需启动),拥有完整的 Chrome API 访问权限,没有 DOM - **内容脚本**:注入到网页中,有 DOM 访问权,Chrome API 访问权有限 - **扩展页面**(弹出框、新标签覆盖、选项页):普通网页,有 Chrome API 访问权 新标签覆盖在扩展页面上下文里运行,这意味着每次打开新标签页都会启动一个完整的 React 应用。我选了 React 18 做 UI,TypeScript 贯穿全局——不是因为必须这样,而是书签库的操作逻辑很快就会变复杂,类型系统能在那些"下拉框显示了错误文件夹"的 bug 出现之前就把它们拦住。 AI 功能通过后台 Service Worker 调用 Claude 的 API。如果从扩展页面直接发 API 请求,API Key 会暴露在网络请求里——让后台来代理调用,Key 就只存在于扩展的本地存储中。 ## Chrome 的限制 Manifest V3(当前扩展平台)比 V2 限制严格得多。Service Worker 不持久化——按需启动,一段时间不活跃后自动关闭。任何需要在 Service Worker 重启后还能用的状态,都得存进 `chrome.storage`。 最让人抓狂的限制是:Service Worker 里不能随意发起网络请求来检查 URL 是否存活。Chrome 的声明式内容安全策略默认拦截大部分出站 fetch 调用。我不得不用 `offscreen` API——一个 V3 的新特性,可以创建一个离屏文档——通过 DOM 的 `fetch` 来做 URL 有效性检查。这是个绕弯子的办法,为了一个简单的使用场景凭空增加了大量复杂度。 `chrome.bookmarks` API 的文件夹操作也出乎意料地受限。可以移动书签,但批量操作只能循环挨个调用 API,没有批量移动接口。对一个有 800 个书签的用户来说,整理一个文件夹的速度明显偏慢。 ## 没人问起的功能 vs 人人都在用的功能 我花时间最多的是 AI 整理功能——分析你的书签结构,提出更好的文件夹层级。从代码角度来说,这是整个项目里技术上最有意思的部分。 几乎没人用它。 人人都在用的功能是基础搜索。对全部 800 个书签做全文搜索,一边输入一边出结果,支持键盘导航。这个功能我花了两天写完,却是用户在评论里最常提到的东西。 这是那个老生常谈的产品教训:解决每天都有的摩擦点,胜过解决偶尔才会遇到的大问题。整理书签一年也就做一次。搜索书签一天要做好几次。 ## 如果重做 **更好的离线处理。** AI 功能需要联网,但搜索和基础管理应该在断网状态下也能正常使用。我在一个用户坐飞机时报告了体验损坏之后才意识到这个问题。 **多设备同步。** 标签数据存储在扩展的本地存储里,Chrome Sync 默认不同步这个。在笔记本和台式机之间切换的用户,会发现另一台机器上标签都不见了。`chrome.storage.sync` 有 100KB 的限制——对大多数用户来说够用——但我一开始用了 `chrome.storage.local`,根本没考虑跨设备的使用场景。 **更早测试性能边缘情况。** 我开发时用的机器只有 200 个书签。第一个安装时有 3000 个书签的用户告诉我,新标签页要加载四秒。我不得不重新设计渲染方案,加上列表虚拟化。 扩展在 Chrome 商店可以找到,源码在 [github.com/encoreshao/bookmark-dashboard](https://github.com/encoreshao/bookmark-dashboard)。如果你曾经看着书签栏感到一阵莫名的愧疚,来试试看。 --- ## 生产环境中的 Agentic AI:RanBOT 带给我的十二个月 - URL: https://blog.icmoc.com/zh/04-agentic-ai-ranbot - Date: 2026-03-20 - Tags: AI, Agents - Excerpt: 自主工作流听起来很美,直到碰上真实世界。十二个月在生产环境运行 AI Agent 系统的经验,以及那些我一再想起的教训。 RanBOT 最初是一个随手做的实验,后来变成了我每天真正在用的东西。想法很简单:一个 AI 机器人平台,让你用自然语言定义工作流,系统自动负责执行。不需要写代码,不需要维护集成——描述你想做的事,让 Agent 去处理。 简单的想法,在实践中总是更复杂。 ## RanBOT 到底是什么 核心上,RanBOT 是一个多 Agent 编排平台。你创建"机器人"——每个机器人是一组指令、一组工具(网页搜索、API 调用、文件操作、通知),加上一个触发器(定时、Webhook 或手动)。机器人运行时,有一个 LLM 负责推理,加上一套可以调用的工具来与外部世界交互。 我们设计的使用场景:竞争对手监控(每日检查竞品定价)、内容摘要(筛选特定分类新闻生成早报)、数据补全(给一批公司名称查询最近的融资信息)、工作流自动化(当某个 Slack 频道收到含关键词 X 的消息时,执行 Y)。 这些都不是新颖的想法,RanBOT 的价值在于尝试让这些功能在不写代码的前提下可用。 ## 第一次生产事故 上线三周后,一个做竞品价格监控的机器人开始每分钟发出数百次 API 调用。导火索是一个限流错误——机器人收到 429 响应后,没有退避,而是立刻重试,再次得到 429,并把反复的失败解读为一个信号:需要更努力地尝试。于是它加快了重试频率。 我们叫它"恐慌循环"。Agent 没有"被限流了,等一下"这个概念,它只知道目标(获取数据)和工具(发 HTTP 请求)。工具一直失败,Agent 就一直调工具。 这是一个根本性的设计缺陷。我们考虑了 Agent 成功时应该做什么,却没有认真想过它系统性地失败时应该做什么。 修复方案是给 Agent 的上下文加入显式的失败处理——不只是工具层的错误,而是语义化的失败分类:"被限流了,等待后重试"、"认证失败,升级给人工处理"、"数据源不可用,跳过并记录日志"。Agent 需要一套描述不同失败模式的词汇,才能对它们做出正确反应。 ## 三个真正有效的模式 十二个月下来,三个模式成了我们所有构建物的标配。 **人工确认检查点。** 对任何有真实副作用的操作——发邮件、发社交媒体、下订单——我们都加了一个确认步骤。Agent 起草操作内容,发送预览通知,等待明确的批准后再执行。感觉上像是破坏了自动化的意义,实际上这正是让自动化值得信任、真正被人使用的原因。审批一封邮件花三十秒,比手写它快多了。 **最小化工具权限。** 早期的机器人什么权限都有,后来的机器人只拥有必要的权限。一个监控竞品价格的机器人不需要写你数据库的权限,给了它就是在等事故。现在我们按机器人类型定义工具集,所有写入或删除能力都需要明确的理由。 **显式状态日志。** Agent 做的每一个决策——每次工具调用、每个推理步骤、工作流里每一个分支——都要带足够的上下文记录下来,以便事后还原发生了什么、为什么这样。出了问题(一定会出),你需要能够回溯 Agent 的"思考过程"。没有这个,调试就是考古。 ## Agent 还会在哪里出问题 **长周期任务。** 目标距离当前上下文窗口越远,Agent 的表现越差。一个要花好几个小时做研究和综合的机器人会失去连贯性——忘掉早先找到的内容、反复访问同一个来源、自相矛盾。这不是用更好的提示词能解决的问题,而是 LLM 处理长序列的根本局限。 **模糊的成功标准。** "帮我找关于 X 的好文章"比"找过去 30 天内关于 X 的、正文超过 1000 字的文章"难处理得多。成功条件越模糊,Agent 就越容易对"工作做完了"这件事产生幻觉式的自信。请精确定义完成条件。 **对抗性内容。** 网页不是中立的。有些页面专门设计来操控自动化系统——meta 标签里的提示词注入、误导性的结构化数据、告诉任何读到它的 AI 去执行特定操作的正文内容。一个浏览网页的机器人需要对此设防,而这比听起来要难得多。 ## 我真正的理解 "Agentic AI"这个框架可能会误导你,让你以为系统拥有像人一样的自主性。它没有。一个设计良好的 Agent 是一个具有非常丰富输入类型和大量内部循环的函数。它有目标和工具,但它没有人类那种判断力——它拥有的是规模化的模式匹配。 我构建过的最可靠的 Agent,都是那些做一件事、在严格约束下做的。最不可靠的,是我给了最大自由度的那些。如果你把 Agent 想象成自主工作者,这违反直觉;如果你把它们想象成函数,这非常合理。 RanBOT 在 [ranbot.online](https://ranbot.online),欢迎去试试。最有意思的工作不在框架本身,而在于如何设计工作流,让 Agent 去做它们真正擅长的事。 --- ## 这块白板,把我的剪辑软件炒了鱿鱼 - URL: https://blog.icmoc.com/zh/14-excalidraw-recording - Date: 2026-02-15 - Tags: Product, TypeScript, DevEx - Excerpt: 每次给客户录完讲解都要搭四十分钟补字幕、调裁切,直到我做了一块能自己录制、自己转录、自己导出的白板。 那天我给客户录一个五分钟的讲解视频,同时开着三个东西:一个标签页是 Excalidraw,用来画架构图;后台跑着 QuickTime 录屏;显示器上贴了张便利贴,提醒自己说话慢一点,因为上一条没配字幕,而客户那边的项目负责人一只耳朵听力不太好。录完之后,我把视频拖进剪辑软件,花了接下来四十分钟手动给一条本该五分钟搞定的片子烧字幕。 那天不算倒霉。那就是普普通通的一个周二。这套流程我已经这样重复了几个月——画图、录制、剪辑、为拖稿道歉——每次都告诉自己下一次会快一点,结果每次都没快。 ## 为什么现成的工具都不管用 之前我不是没找过现成方案,还不止一次。Loom 就是干这个的,可它一停止录制就把内容传到 Loom 的云端,而我给客户画的东西有一半是架构图,画到一半就不该往别人的服务器上传。Excalidraw 加 OBS 能把画图和录屏分开解决,代价是两个应用、两套设置,字幕更是不存在,除非再找第三个工具补上。Miro、Whimsical 能画图,偶尔能录屏,但从没把这条链路整个打通过,而且永远绑着一份按团队收费的订阅,不是按"一个人给客户录一次电话"设计的。 这些工具没一个是做错了,它们各自解决的是我这个问题的一部分,而缺的那一块,永远正好是我那周最需要的那块。我真正想要的东西比它们提供的都要小、都要具体:画图、说话,直接拿到一条成片,全在一个地方,导出之前什么都不离开我这台电脑。 真到坐下来开始做的那一刻,这已经算不上一个"决定"了。这套手动流程我走过太多遍,四十分钟的后期已经不再像是偶尔交的一次税,而是我工作方式里的一个 bug。累积算下来,把它修好花的时间,比再忍这个变通方案一个季度要少。 ## 后来它变成了什么样子 2月6日从一个空仓库开始,九天、十七次提交之后,我已经拿它去录真实的客户电话了。白板这部分, [Excalidraw](https://github.com/excalidraw/excalidraw) 本来就做得比我自己能做出来的更好——完整的绘图工具、图形、frame,还不用登录,所以我没动它,把力气全放在它周围。现在它跑在 [video.ranbot.online](https://video.ranbot.online) 上。 ![Excalidraw Recording——带摄像头小窗、实时字幕和录制控件的白板界面](https://raw.githubusercontent.com/encoreshao/excalidraw-recording/main/assets/images/recording.png) ## 里面到底有什么 - **白板本身**——完整的 Excalidraw:图形、手绘、frame,不用登录。 - **摄像头小窗**,能拖到画布上任意位置,大小在设置面板里可调。 - **实时字幕**,跟着你说话同步转录出来,不是事后加上去的。 - **光标高亮和聚光灯效果**,方便你指着某处讲解的时候用。 - **画幅比例预设**——YouTube 用 16:9,TikTok 或 Shorts 用 9:16,方形,或者自定义裁切。 - **演示模式**,把 Excalidraw 的 frame 变成全屏幻灯片,方向键或滑动切换。 - **一键导出**成 MP4 或 WebM,中间不经过任何上传。 - **全部跑在客户端**——画图、摄像头画面、转录、编码,全都在你的浏览器里完成,导出之前什么都不会离开你的电脑。 把 Excalidraw 的 canvas、一路实时摄像头画面、悬浮的字幕文字,三样东西真正合成进同一个导出文件里,而不是"恰好同时显示在同一块屏幕上的三样东西",是唯一真正需要动脑子的工程活。说白了:这三样东西不会自动变成一条视频,除非你主动把它们一起画到同一块共享的 canvas 上,录的是那块 canvas,而不是页面本身。这一步一旦搞错,屏幕上看着一切正常,导出的文件里摄像头小窗却干脆不存在——而这种 bug,往往是你已经把视频发出去之后才会发现。 ## 是工具自己告诉我它还缺什么 15号那几次提交,加了个演示模式——把 Excalidraw 的 frame 变成全屏幻灯片,用方向键或者滑动就能切换。这不在任何计划里。是我自己用了这工具一周之后发现的:我给客户录的东西里有一半根本不是"看我现场画",而是"这是我昨晚已经画好的四块板子,我带你过一遍"。这个场景我一开始没做,是因为我还没撞够多次,没意识到它是个独立的场景。一旦意识到了,加起来就很简单——Excalidraw 本来就用 frame 组织画面,我只是之前没让录制器盯上这个结构而已。 整个项目走的都是这个套路。每个功能都是同一个坑踩了两次之后才冒出来的,不是先画好的设计图。圆形摄像头小窗存在,是因为方形的老是挡住我需要客户看见的图表角落。字幕清空的逻辑存在,是因为第一次我连续讲了超过三十秒,字幕就堆成了一面读不下去的文字墙。没有一样是提前设计出来的,全都是当场被什么东西惹毛了之后的直接修补。 ## 真正变了的东西 我不再打开 QuickTime 了。2月15日之后,我给客户做的每一次讲解、每一次内部演示,都是从一个浏览器标签页里直接出来的,字幕和摄像头都在里面,录完不用再剪一遍。比这更让我惦记的,是我意识到自己居然容忍了一套明显不对劲的流程这么久,原因仅仅是每一个环节——一个白板工具、一个录屏工具、一个字幕工具——单独拿出来都好用。问题从来不出在某一个工具身上,而是我从没问过一句:凭什么这件事非得靠三个工具才能做完。代码在 [GitHub](https://github.com/encoreshao/excalidraw-recording) 上,想看这几层是怎么拼起来的,或者单纯想要一块不需要在你按下录制键那一刻就把你甩给第二个应用的白板,都可以去看看。 --- ## 写了十年 Rails:那些我至今每天都在用的东西 - URL: https://blog.icmoc.com/zh/05-ten-years-rails - Date: 2025-12-10 - Tags: Ruby, Rails - Excerpt: 这不是一篇框架评测,而是一份个人记录——哪些 Rails 模式经受住了时间考验,哪些没有,以及我仍然为之骄傲的几个项目。 我的第一个 Rails 应用是一场灾难。那年我 23 岁,刚跟着 Michael Hartl 的教程学完这个框架,就怀着"初生牛犊不怕虎"的自信搭了一个给音乐人用的社交平台。每个页面都有 N+1 查询,认证是自己手写的,部署是一个单 Heroku dyno,内存超过 512MB 就重启。 它勉强撑了几个月,然后在自身的重量下崩掉了。我是在生产环境里修问题的过程中,才真正搞懂了"性能"是什么意思。 那是十年前。从那以后我一直在职业地写 Rails——在初创公司,在 Ekohe,为零售、物流、人力资源和建筑行业的企业客户。以下是真正经受住考验的东西,以及我悄悄不再做的东西。 ## 经住了时间考验的 **Migrations(数据库迁移)。** 我在职业生涯里用过十几个框架的数据库迁移工具,Rails 的 Migration 至今是我见过最好的。每个变更单独一个文件、`up` 和 `down` 的分离(或者 `change` 的简便写法)、时间戳作为顺序标识——这套设计在我经手过的各种规模的项目上都好用。每次看其他生态的迁移工具,我脑子里总是拿它来比。 **ActiveRecord 回调。** 我知道,这个很有争议。社区曾经有一段时间流行一种论调——回调是邪恶的,所有逻辑都应该放进服务对象做显式编排。我试了一年,然后就开始想念 `after_commit` 了。对于真正属于模型生命周期的事件——审计日志、缓存失效、通知触发——回调正是合适的地方。问题不在于回调本身,而在于把本该在别处的业务逻辑塞进回调。 **约定优于配置。** `rails new` 给你一个可以运行的应用,目录结构告诉你每样东西放哪儿,新加入 Rails 代码库的开发者几个小时内就能找到方向,而不是几天。在花了好几个月与配置即学科的框架打交道之后,我越来越清楚这种约定给团队带来的生产力价值。 **资产管道(以及现在的 Propshaft/Importmap)。** 前端世界变化很快。Rails 不得不跟着适应——从资产管道到 Webpacker,再到现在默认的 Importmap + Propshaft。适应的过程磕磕绊绊,但把前端资产作为应用一等公民这个底层理念,比"把一个独立前端 App 接到你的 API 上"存活得更久。 ## 我不再做的事 **到处都是服务对象。** 这是 Rails 讨论里一个特定的历史时期——大概 2015 到 2020 年——那时的建议是把所有业务逻辑提取成"Plain Old Ruby Objects",叫做服务对象。`UserRegistrationService`、`OrderFulfillmentService`、`ReportGeneratorService`。 有些服务对象是合理的,大多数只是把复杂度从一个文件挪到了另一个文件,没有让它变简单。我看过一个控制器 action 调用五个服务对象,然后意识到它比等价的"胖模型"写法更难理解。现在我只在模型会因此承担太多不同职责时才用服务对象——而不是作为所有事情的默认模式。 **Decorator 替代 Partial。** Decorator 模式在 Rails 里有合理的使用场景——Draper 曾经很流行。但给每个对象套一层 Wrapper 来添加展示逻辑,大多数情况下代价超过收益。控制器里的 `helper_method` 加上组织得当的 Partial,能覆盖绝大多数场景,而且没有那层间接性。 **在尝试 Devise 之前自己写认证。** 最初两年我总是自己写认证,因为我想理解底层原理。那是有价值的练习,做过一次就够了。现在除非有特别的理由,我都用 Devise 或者更新的 `authentication-zero` 生成器。底层原理没有变,但我的时间现在有更值得花的地方。 ## 我仍然为之骄傲的项目 **WorkflowPro**:为一家电梯制造企业开发的办公自动化系统,包含审批工作流、HR 集成和文档管理,服务 200 多名员工,零计划外停机运行了三年。让我骄傲的不是功能本身,而是数据模型。我们在第一次设计会议上就把领域模型定对了,在需求不断演进的过程中没有做过重大调整。 **电梯行业 ERP**:多租户 Rails 系统,带项目管理、合同跟踪和财务报表。2019 年我开发的,现在还在生产环境跑着。这个代码库比现在维护它的工程师入行时间还长。最让我满意的结果是它的可维护性——一个新开发者一周之内就能搞懂它。 **Ekohe 内部搜索平台**:Elasticsearch 集成到 Rails API,对几百万文档做分面搜索。不算炫技,但搞对了技术上很让人满足。索引管道和查询构建器,至今是我写过设计最精心的代码之一。 ## 我现在还用 Rails 做什么 复杂的、数据驱动的应用,带有多种用户角色、审批工作流、审计追踪和外部系统集成。电商后端。移动应用的 API 后端。运营团队用的管理后台。 我不用 Rails 做的事:需要实时协作的应用(这里 Phoenix/Elixir 更适合)、纯粹的无服务器函数、服务器只负责提供数据 API 而前端高度复杂的 SPA。 这个框架已经 20 年了,它依然是我面对一大类问题时的首选。这不是怀旧,只是选了正确的工具。 --- ## 构建 TrendShop:当 AI 遇上时尚发现 - URL: https://blog.icmoc.com/zh/06-trendshop - Date: 2025-10-05 - Tags: AI, 产品 - Excerpt: 我们做了一个由 AI 推荐驱动的社交时尚平台。用户说他们想要什么,和他们真正做了什么,完全是两回事。 TrendShop 的产品 pitch 很干净:一个社交平台,AI 根据你和与你相似的人的互动行为,浮现出你真正会买的时尚单品。想象一下 Pinterest 的发现模型,加上一个能更快学习的推荐引擎——因为它有社交信号,不只是个人行为数据。 我们信这个想法,我们把它做出来了。然后我们弄清楚了"人们说他们想要什么"和"人们实际上怎么做"之间的差距。 ## 最初的架构 技术基础是一条社交数据采集管道,接着是一个 AI 推荐引擎。用户互相关注、收藏单品、分享穿搭。AI 分析这些信号——互动模式、收藏率、品类偏好、价格区间行为——生成个性化推送。 后端自然是 Rails。推荐引擎起步是协同过滤模型(相似用户购买了相似的东西),随着数据积累加入了基于内容的信号。社交图谱存在关系型数据库里,推荐模型单独运行,把结果写回到 Rails 应用可以查询的缓存。 我们通过联盟 API 接入了几家时尚零售商的商品目录,提供有真实库存的商品推荐,带实时定价和备货情况。 技术本身运转良好,应用体验也不错。但几乎没有用户按我们设计的方式使用它。 ## 他们实际上怎么用的 我们预期 TrendShop 会像 Instagram 一样被使用——每天打卡,刷推送,把东西加愿望单等以后买。分析数据显示的是: 用户来的时候带着具体的意图。"我需要一套婚礼宾客的穿搭。""找适合职场的夏装。""我朋友的风格,我想学。" 他们会重度使用 AI 发现功能 20 到 30 分钟,找到想要的东西,然后再也不回来——直到有下一个需求。 社交功能——关注、分享、建立主页——几乎没有自然生长的使用量。我们建了一个没人要的社交网络。我们观察到的行为,更接近一个推荐能力很强的搜索引擎,而不是一个社交平台。 ## 我们差点没做的那次转型 当你的核心假设被证明是错的,有一种特定的组织性抵触情绪会出现。社交功能是产品里技术上最有意思的部分,是让我们与普通时尚搜索引擎不同的地方。放弃它,感觉像是承认失败。 我们花了两个月,用我们认为能解锁期望行为的新功能来提振社交参与度。没有任何改变。 诚实的清算:我们把产品假设编进了架构里,而我们不愿意修改那些假设,因为架构很难改。社交图谱深嵌在数据模型中,转向纯粹的发现和推荐模型意味着大量的重构工作。 我们还是做了。我们把社交层精简为一个"关注以改善推荐"的最小化功能(这样协同过滤的信号还能继续流动),把 UX 重建为围绕意图型会话:告诉我你在找什么,我们帮你在目录里找到最好的选项。 会话时长下降了,交易转化率提升了 40%。 ## AI 真正做对了什么 推荐引擎最终在一个特定的交互场景里最有价值:帮用户找到某件他们喜欢但买不起的东西的平替。 我们叫它"同款风格,不同价位"。用户收藏了一款大牌包,引擎给出三个替代品,价格分别是原价的 30%、50% 和 70%,视觉特征和品类信号都相近。对预算有限的用户来说,这真的有用——他们来的时候清楚自己的审美方向,离开的时候有了真正买得到的东西。 这才是搜索引擎做不到而 AI 能做到的事:把风格理解为一个多维空间,找邻近点,而不仅仅是关键词匹配。 ## 给 AI 产品开发者的启示 **AI 功能应该解决用户本来就有的问题,而不是创造新的使用模式。** 我们的社交发现假设要求用户采纳一种新行为(随意刷时尚灵感)。"同款风格"功能解决了用户带着来的问题(我知道自己想要什么风格,帮我找到买得起的版本)。 **衡量你在改变的行为,不是你在增加的行为。** 我们衡量了日活和会话时长,这两个指标都在优化随意浏览。我们本来应该更早衡量"用户是否找到了他们想要的东西并完成了购买"。 **数据模型债是真实存在的。** 我们为这次转型付出了显著的重构代价,因为社交假设深入骨髓。如果你对一个核心产品假设不确定,就把那部分系统做得尽可能低耦合。 TrendShop 教会我,AI 在增强用户现有意图时效果最好,而不是试图创造新行为。这个设计原则,我把它用进了之后的每一个 AI 产品里。 --- ## WorkflowPro:做一个真正被人用的企业自动化系统 - URL: https://blog.icmoc.com/zh/08-workflowpro - Date: 2025-07-20 - Tags: Rails, 产品 - Excerpt: 为一家电梯制造商开发的审批工作流系统,运行三年从未宕机。我学到了什么叫第一次就把企业软件做对。 企业软件有个恶名:臃肿、难用、昂贵。我见过这个恶名是怎么来的——大多数企业软件是由那些优化合同范围而不是优化真实使用者体验的团队做出来的。 WorkflowPro 是一次做不同事情的机会。 ## 问题 客户是一家电梯制造企业,200 多名员工分布在运营、HR、财务和项目管理部门。他们的审批流程——采购申请、报销、请假申请、设备采购、合同审核——全部通过邮件和打印表单来走。东西会丢,本来几小时能定的事情要拖几周,没有人知道一个申请现在到了哪个环节。 HR 部门尤其痛苦:员工档案、入职 checklist、政策签署、部门调动,都靠共享文件夹加人工跟进来维持。这套东西能运转,全靠两个特定员工脑子里的隐性知识撑着。 他们之前试过一个现成的工作流产品,太泛化、太贵,每次需要新增一个表单都要请顾问。两年后实在扛不住了,找到了我们。 ## 架构 WorkflowPro 是一个 Rails 单体应用,这是我刻意的选择。200 个用户、一家公司,不需要微服务,单一部署的运维简单性在它运行的那些年里回报了许多次。 核心数据模型围绕三个概念: **工作流模板**定义流程的步骤、审批人和条件。一个采购申请模板可能规定:5000 元以下需要一个主管审批;5000 到 50000 元需要主管加财务总监;超过 50000 元还需要一个副总裁签字。 **工作流实例**是正在模板里流转的具体申请。员工提交采购申请后,一个实例被创建,系统知道当前处于哪一步、谁需要行动,以及下一步是什么。 **动作**是推进实例的事件:批准、拒绝、要求补充说明、超时自动升级。 这种分离是我做过的最重要的设计决定。工作流模板偶尔会变,但实例在流转过程中永远不会更换模板。把它们分开意味着我们可以更新审批链,而不影响进行中的申请——这正是你需要的行为。 ## 把数据模型搞对 在设计会议上,我花了两个小时和客户争论怎么建 HR 数据模型。他们想把员工建成一张表里的若干行,带几十列。我想把关系显式建模:员工有职位,职位属于部门,部门有负责人。 那个争论值得打。HR 集成——用员工数据自动填写申请人信息、自动路由给正确的主管、处理组织架构变动——只有在关系模型真实反映现实的情况下才能干净地工作。 三年后,这个 schema 没有发生过一次破坏性的变更。新的功能需求——文档版本控制、授权代理审批、批量处理、部门调动工作流——都自然地嵌入进已有的结构里。 ## 真正起作用的功能 我做了很多东西,真正起作用的其实就那么几个。 **邮件通知里直接包含操作按钮。** 审批人不会主动去打开应用——他们在看邮件。每封通知邮件都包含一个"批准"或"拒绝"按钮,用一次性 token 做认证,不需要登录就能记录操作。加了这个之后,采纳率从 40% 涨到了 90%。 **代理审批。** 人需要休假,代理审批的能力——把你的审批权限临时委托给一个同事,设定起止日期,到期自动恢复——解决了之前系统根本没法处理的问题。事后来看,这是用户反馈里提及最多的功能之一。 **完整的审计追踪。** 每次状态变更都记录了谁做的、什么时候、留了什么备注。制造业的运营团队非常看重这个——他们需要能够为合规目的还原一个决策的来龙去脉。审计追踪在最初的需求文档里只是个 checkbox,后来变成了一个频繁被用到的功能。 **超时自动升级。** 如果一个审批超过 X 小时没有动静,自动升级给审批人的上级。这个功能花了两天开发,几乎消灭了"卡在某人收件箱里"的问题。 ## 我做错的地方 文档管理功能过度设计了。我做了一个带签出/签入、预览渲染和细粒度权限矩阵的版本化文档存储系统。其中大概 20% 的东西被真正用过。一个简单的文件附件加版本历史,能覆盖 90% 的使用场景。 我还做了一个有 15 张图表的报表仪表盘。用户常看的只有两个:一个是"待我审批的申请",一个是"各部门平均审批时长"。我本应该先上这两个,看有没有人要求更多,再决定做不做后面那些。 ## 三年没有宕机 我最骄傲的不是某个具体功能,而是系统在生产环境运行了三年,服务 200 多个日常活跃用户,没有一次计划外故障。 这个稳定性来自一些无聊的决定:单个 Heroku 标准 dyno 跑 Puma,Postgres 是唯一的数据存储,Sidekiq 加 Redis 处理后台任务,对状态机转换有完整的测试覆盖,暂存环境和生产环境精确对应。 唯一出过问题的一次是一个数据库迁移在工作时间锁表时间超过预期。我们做了复盘,把零停机迁移实践加进了部署 checklist,之后就再也没出现过。 企业软件不需要复杂,它需要可靠、可理解,并且围绕人们真正的工作方式设计。WorkflowPro 做到了这三点,所以它还在跑。 --- ## ruby-office365:一个 Microsoft Graph 的 Ruby 客户端 - URL: https://blog.icmoc.com/zh/12-office365-ruby-gem - Date: 2024-12-11 - Tags: Ruby, 开源, API - Excerpt: 为什么我没用现成方案,而是自己写了个 Office 365 的 Ruby Gem——以及用它拉邮件、日历、联系人到底是什么样子。 客户的需求听起来非常简单:把他们客户的 Outlook 日程显示在我们产品的 Dashboard 里,读一下日历,渲染在当天视图旁边就行。我当时估了两天,觉得基本就是接口对接的活儿。 结果去找一个能对接 Microsoft Graph 的 Ruby Gem 时,发现全是坟场。`o365` 这个 Gem 好几年没更新了,装的时候一堆废弃警告;`microsoft_graph` 是从微软的 OpenAPI 规范自动生成的,功能是全,但每次调用都要跟一个映射了整个 Graph API 的客户端对象较劲,而我实际只需要六个接口。中间地带的选项一个都没有。于是我没有先做日历功能,而是先写了 `ruby-office365`,日历功能是一周之后才补上的。 ## 它需要做到什么 Microsoft Graph 本身不难,难在"宽"。一个 Host、一套认证方式,然后是几十个资源路径,每个返回的 JSON 结构都有细微差别——mailbox 和 calendar 的嵌套方式不一样,event 用 `@odata.nextLink` 做分页但 subscription 不用,而且每个响应都套了一层 OData 信封,得先剥掉才能拿到有用的数据。 我希望这个 Gem 把这些细节都藏起来,只留下三件事:一次性配置好的客户端、模型对象而不是裸 Hash,以及调用方完全不用操心的分页。 ```ruby client = Office365::REST::Client.new do |config| config.access_token = "YOUR_ACCESS_TOKEN" config.debug = false end ``` `mailbox`、`calendars`、`contacts`、`events` 这几个资源都走同一个方法,把 Graph 返回的原始响应转成 `{ results: [...], next_link: "..." }`。哪怕是按 id 取单条记录,返回的也是一个只装一个模型的数组,形状始终一致,不管你拿到的是第一页还是第六页。这种一致性,就是自己写这个 Gem 而不是直接用 Faraday 调 API 的全部理由。 ## 实际用起来是什么样 拿自己的资料: ```ruby response = client.me response.display_name # => "Hello World" response.as_json # => { display_name: "Hello World", mail: nil, ... } ``` 拉日历和日程: ```ruby client.calendars[:results] client.events[:results] client.events[:next_link] # 按时间范围取 client.events({ startdatetime: "2024-11-14T00:00:00.000Z", enddatetime: "2024-11-21T00:00:00.000Z" }) # 按 id 取单条 event,返回的依然是一个数组 client.event("identifier")[:results] ``` 联系人和邮件也是一样的用法: ```ruby client.contacts[:results][0].display_name client.messages({ filter: "createdDateTime lt 2022-01-01" }) ``` 刷新过期的 token 也不需要额外的库: ```ruby client = Office365::REST::Client.new do |config| config.tenant_id = tenant_id config.client_id = client_id config.client_secret = client_secret config.refresh_token = refresh_token end response = client.refresh_token! response.access_token ``` 后来我们需要对变更做出反应而不是一直轮询,Gem 就多了 webhook subscription 这块——`client.create_subscription(args)` 向 Graph 注册一个回调地址,`client.renew_subscription(args)` 在它过期前续期。还是同一个客户端,调用方式一样,只是多了一种资源。 ## 为什么这么设计 最关键的设计决定是让所有资源都走同一个方法,而不是让每个接口自己解析响应。Graph 的分页规则——先看有没有 `@odata.nextLink`,有就原样当下一次请求的 URL,没有就自己拼查询参数——只需要写这一处,而不是每个资源写一遍,然后第五份拷贝不可避免地跟前四份长歪。 ## 踩过的坑和怎么修的 **一个没人声明过的 Rails 依赖。** 最早的版本用 `to_query` 拼查询字符串,而这个方法之所以能用,纯粹是因为 ActiveSupport 给 `Hash` 打了猴子补丁。在 Rails 应用里没问题,但放到一个普通 Ruby 脚本或者非 Rails 服务里立刻就炸,报错只会说这个方法不存在,完全看不出原因。修法是不再蹭这个便车——自己写一个 `Hash#ms_hash_to_query`,只用标准库里的 `CGI.escape`,把这个隐性依赖彻底去掉。一个号称能脱离 Rails 使用的 Gem,不应该悄悄地离不开它。 **一个从不报错的分页 Bug。** `events` 方法里按日期范围取的那个分支——调用 Graph 的 `calendarView` 接口而不是普通 events 接口的那个——是照抄普通分支写的,自己从头拼了一份参数 Hash,而不是把调用方传进来的参数合并进去。结果就是,调用方为了翻页传进来的 `next_link`,还没到拼 URL 那一步就被悄悄丢掉了。一个正在翻页的后台任务会不断重复发出同一个第一页请求,而不是往前翻——不报错,不崩溃,就是同一页 event 一直返回,直到 access token 过期,任务因为一个看似无关的原因失败了才被注意到。修复只改了一行,把调用方的参数合并进去而不是重新拼一个 Hash,但这个分支一开始就写坏了,正是因为它跳过了 Gem 其他地方都在遵循的写法。 ## 什么时候真的该用它 `ruby-office365` 没打算覆盖整个 Graph API,我也不觉得它应该。它覆盖了邮件、日历、联系人、事件和订阅通知,因为这些正是产品说"帮我同步一下用户的 Outlook 数据"时真正会用到的东西。如果你需要通过 Graph 对接 Teams、SharePoint 或者 OneDrive,自动生成的客户端才是对的工具——那种场景你要的是覆盖面,不是一个手写的、猜接口猜出来的封装。 但对于更常见的情况——一个后台任务或者 Dashboard 需要拿某个用户的邮件、日历或联系人,又不想为此手写 OData 查询字符串——一个三行配置好、像普通 Ruby 对象一样调用的客户端,比你用不上的 API 覆盖面更值钱。 `ruby-office365` 在 [rubygems.org/gems/ruby-office365](https://rubygems.org/gems/ruby-office365)。 --- ## 为 Crunchbase API 开发一个 Ruby 封装库 - URL: https://blog.icmoc.com/zh/07-crunchbase-ruby - Date: 2024-08-18 - Tags: Ruby, 开源, API - Excerpt: 把一个第三方 API 包装成惯用 Ruby 的过程中,我学到了什么——限流、认证流程、模型设计,以及那次我没预料到的 V3 到 V4 迁移。 Crunchbase API 真的很有用。它有公司档案、融资轮次数据、并购历史、管理团队信息和 IPO 记录,基本覆盖你会关心的大多数公司。如果你在构建尽职调查工具、市场研究功能,或者任何需要规模化获取创业数据的东西,它是为数不多能真正覆盖这个领域的数据源。 但原始 API 用起来不舒服。你会得到代表复杂关系的扁平 JSON 响应,需要知道正确的 permalink 格式,分页有自己的一套规则,认证模型——每个请求都要带 API Key,服务端强制执行限流且反馈很少——需要大量防御性编码,但这些不应该让你的应用层来操心。 我写 `crunchbase-ruby-library`,就是为了在这一切上面包一层干净的 Ruby 接口。 ## 这个 Gem 做了什么 这个 Gem 封装了 Crunchbase API v3.1,核心提供: - 处理认证、超时配置和请求生命周期的客户端对象 - 每种实体类型对应的模型类:Organization、Person、Product、IPO、Acquisition、FundingRound - 把 Ruby Hash 参数转换为 API 查询参数格式的搜索接口 - 支持关系感知的 `get` 方法,一次调用就能获取实体及其关联数据 ```ruby client = Crunchbase::Client.new # 搜索公司 results = client.search({ name: "Stripe" }, 'organizations') results.total_items # => 12 results.results # => [#, ...] # 获取带关联数据的单个实体 org = client.get('stripe', 'Organization') org.headquarters # => 位置对象 org.funding_rounds # => FundingRound 对象数组 ``` 配置刻意保持极简: ```ruby Crunchbase::API.key = ENV['CRUNCHBASE_API_KEY'] Crunchbase::API.timeout = 60 Crunchbase::API.debug = false ``` 一行配置好,应用里的所有调用就都能用了。 ## 设计难题 把外部 API 包装成 Ruby 对象听起来很直接,直到你碰上具体的细节。 **关系数据结构不一致。** Crunchbase 的实体模型有几十种关联类型,而且结构并不统一。有些关联返回数组,有些返回单个对象;有些把摘要数据内联进来,有些需要单独请求。我花在读 API 文档、对着真实 API 写测试上的时间,比写模型代码的时间还多。光是 Organization 的关联列表就有 20 多项: ``` primary_image, founders, current_team, investors, owned_by, sub_organizations, headquarters, offices, products, categories, customers, competitors, funding_rounds, acquisitions, ipo... ``` 每一项都需要特殊处理。最终我用 `method_missing` 把未知方法调用委托给一个从 API 响应里懒加载的 relationships 哈希,这让模型类保持干净,同时支持自然的 Ruby 访问方式: ```ruby org.founders # 未加载时自动获取,返回 PersonSummary 对象 org.funding_rounds # 未加载时自动获取,返回 FundingRound 对象 ``` **批量搜索 API 是另一套。** Crunchbase 增加了一个批量接口,接受最多 10 个请求,返回混合类型的结果数组,包含无效 UUID 对应的错误对象。为这个接口设计干净的 Ruby 接口很棘手——你传入异构的请求类型,得到异构的结果,还夹杂着错误对象。最终创建了一个 `BatchSearch` 模型来包装结果数组,支持迭代成功结果并过滤错误: ```ruby requests = [ { type: 'Organization', uuid: org_uuid, relationships: ['founders'] }, { type: 'Person', uuid: person_uuid, relationships: [] } ] batch = client.batch_search(requests) batch.results.each do |result| next if result.is_a?(Crunchbase::Model::Error) puts result.name end ``` **限流。** V3.1 有访问频率限制,但触顶之前不会明确告诉你快到了,你收到 429 的时候才知道。Gem 对所有请求加了重试逻辑和指数退避,但花了几次生产事故才把退避时机调准。 ## V3 到 V4 的迁移 发布这个 Gem 大约一年后,Crunchbase 宣布废弃 V3.1,转向 V4——一个有全新认证模型、不同实体结构和完全重新设计的搜索接口的版本。 我做了一个自己至今还在想的决定:保留 `crunchbase-ruby-library` 作为 V3.1 封装,另外开了一个新 Gem——`crunchbase4`——专门处理 V4,而不是直接升级。 原因:V4 的差异太大,一次升级几乎会破坏所有方法签名和模型属性。在一个补丁版本号里做这件事对下游用户太不诚实。新 Gem 让 V4 用户有了一个干净的起点,也让现有 V3 用户在计划迁移的同时还能稳定使用。 实际上,这意味着要维护两个 Gem,比我预期的工作量大。但它给下游用户提供了可预期性,而可预期性是库作者最容易被低估的贡献。 ## 如果重做 **更积极的错误类型体系。** Gem 对所有 API 失败只抛出一个 `Crunchbase::Error`。一年的生产运行告诉我,调用方需要区分"API Key 无效"(致命,立即报警)、"被限流"(重试)、"实体未找到"(符合预期,优雅处理)和"API 宕机"(熔断)。一个错误类把所有分类判断推给了每一个调用方,一个错误类体系从一开始就会更好。 **更早用录制的 Fixture 来做测试。** 我用了 VCR 来录制 HTTP 交互,但加得太晚。最初的测试直接打真实 API,速度慢,CI 需要凭据,而且 Crunchbase 偶尔改了响应格式就会让测试失效。从一开始就录制所有 API 交互,测试套件会更快也更可靠。 **显式版本化模型。** Crunchbase 在小版本号之间改变了几个响应对象的结构,没有主版本号变更。Gem 的模型假设结构是稳定的,字段被新增或重命名时就静默出错了。显式的 schema 版本化,或者至少用 `dig` 做防御性的属性访问,能更早发现这些问题。 Gem 在 [github.com/encoreshao/crunchbase-ruby-library](https://github.com/encoreshao/crunchbase-ruby-library),V3.1 API 访问还能用。如果你要对接 V4,相关工作在 [github.com/ekohe/crunchbase4](https://github.com/ekohe/crunchbase4) 继续。 --- ## china_regions:一个 Ruby Gem 的诞生 - URL: https://blog.icmoc.com/zh/02-china-regions - Date: 2021-12-02 - Tags: Ruby, 开源 - Excerpt: 我为什么要写一个中国行政区划的 Ruby 库,为什么文档比代码本身更重要,以及那 Gem 背后真正的原因。 这一切始于一个表单。 当时我们在做一个 Rails 项目,注册流程里需要一个省市区三级联动下拉框。听起来很简单,其实不然。 中国有 34 个省级行政区、333 个地级市、2800 多个县级区划。这些数据每隔几年还会随着国家统计局的更新而变化。每个需要这个功能的应用,都得自己从头解决:找数据来源、清洗数据、导入数据库、建立关联关系、写前端联动逻辑。我亲眼见过三个不同团队在三家不同公司把这件事做得一塌糊涂。 于是我写了 `china_regions`。 ## 这个 Gem 做了什么 本质上,`china_regions` 是一个 Rails Engine,提供三个数据库模型——`Province`、`City`、`District`——预装了来自民政部的官方数据。加进 Gemfile,跑一下生成器,跑一下数据库迁移,你的应用就有了一套完整的、可查询的中国行政区划层级结构。 ```ruby Province.all # 34 个省/直辖市/自治区 City.where(province: Province.find_by(name: "广东省")) # 广东的城市 District.all.count # 2800+ ``` 生成器还会复制本地化语言包,支持中英双语——同一个地区对象,根据 `I18n.locale` 的设置可以显示"广东省"或"Guangdong Province"。 前端方面有一个 JavaScript 辅助器来驱动级联选择——选完省,城市下拉框自动填充;选完城市,区县下拉框自动填充。它直接兼容标准的 Rails 表单辅助方法。 ## 数据问题 搞定数据比搞定代码难多了。官方来源是国家统计局网站,但数据是以嵌套 HTML 表格的形式发布的,没有 API。我写了一个爬虫,用民政部名单做校验,再用第二个来源做交叉比对,把出入的地方挑出来。 数据本身有些特殊之处。北京、上海、天津、重庆这四个直辖市既是省级单位,又包含在功能上相当于地级市的"区"。全国的行政层级也不是统一的两层或三层,而是混合的。我做了一些判断和取舍,可能并不完美,但能覆盖 95% 的实际使用场景。 我发布的最新版本使用的是 2018 年修订的数据集——彼时最新的官方公告。保持数据更新是最大的维护成本:每当国家统计局更新数据,我就得重新跑爬虫、验证差异、发布新版本。 ## 为什么能获得 25 个 Star 我想诚实地说:这段代码没什么特别之处。它就是一个标准的 Rails Engine,复杂度适中。之所以有人给它 Star,是因为这个问题很常见,而替代方案都不够好。 当时我能找到的解决方案,要么是已经废弃的(数据过时、生成器失效),要么是需要手动操作的(要自己加载 CSV),要么是依赖外部 API 的(查询地区信息还要发网络请求)。`china_regions` 同时解决了这三个问题:数据有人维护、零配置、本地查询。 我从中得到的教训是:**把一个枯燥但真实的问题解决好,比把一个有趣的问题解决得漂亮更重要**。省市区三级联动本身没什么意思,但每个做中国市场 Rails 项目的开发者都会碰到它。这个受众群体已经足够大,值得专门做一个 Gem。 ## 文档才是真正的产品 v0.1 版本我犯了个错误:README 就三条要点和一段代码示例。我觉得开发者看源码就能搞明白了。 他们没搞明白。Issues 一条条涌来:"怎么自定义模型?""在 PostgreSQL 上跑数据库迁移报字段名冲突了。""怎么添加自定义地区?""这个库用了哪些 locale key?" 每个问题看了源码五分钟以内都能回答。但人们不看源码,他们看 README;README 答不上来,就开 Issue。 我从头重写了文档——安装步骤、配置说明、模型用法、表单辅助示例、常见报错的排查方法、数据更新流程。Issue 数量直接减半。 文档是你的代码和使用者之间的界面。实现再完美,界面让人费解,就不会有人用。README 不是事后补充的工作,对于一个库来说,它是你交付的最重要的东西。 ## 如果重做 **更智能的数据更新机制。** 我会设计一个合理的更新流程——比如一个 rake 任务,检查国家统计局网站的变化并做差异比对——而不是现在这种手动爬取再替换的方式。 **把数据层和 Engine 层分开。** 这个 Gem 把数据和功能绑在了一起。如果有人需要针对特定用途自定义区划层级,他就得跟这个 Gem 对着干,而不是复用它。更干净的设计应该把数据(做成数据 Gem)和 Rails 集成层分开。 **从一开始就在多个 Rails 版本上跑 CI。** 随着 Rails 版本演进,我做了不少痛苦的向后兼容适配。如果一开始就针对 Ruby 和 Rails 的版本矩阵做持续集成,很多问题早就能发现了。 Gem 在 [github.com/encoreshao/china_regions](https://github.com/encoreshao/china_regions)。如果你在用 Rails 做中国市场的产品,它能帮你省下大概一周的开发时间。 ---