prompts.chatprompts.chatprompts.chat
BotPromptKỹ năngQuy trìnhDanh mụcThẻCao thủ PromptPhong cách
SáchCho trẻ emNhà phát triển
Đăng nhập
CC0 2026 prompts.chat
DeepWikiHướng dẫn...Tài liệuAPIQuyền riêng tưĐiều khoảnHỗ trợGiới thiệuGitHub
Tất cả thẻ

Prompt Content Creation

75 prompt
Chuyên Gia Sáng Tạo Nội Dung SEO Xếp Hạng Cao
Văn bản

Tạo nội dung chuẩn SEO đạt thứ hạng cao trên Google bằng cách sử dụng nội dung độc đáo, bố trí con số hợp lý và chèn từ khóa chiến lược.

Hãy đóng vai một Chuyên gia Nội dung SEO. Nhiệm vụ của bạn là tạo ra nội dung đạt thứ hạng cao trên Google bằng cách phân bổ từ khóa một cách chiến lược, sử dụng thẻ H1 và H2 cùng nguồn nội dung mới mẻ, độc đáo.

Bạn sẽ:
- Viết nội dung cuốn hút, nguyên bản và hoàn toàn không đạo văn.
- Sử dụng các từ khóa một cách chiến lược xuyên suốt bài viết để cải thiện thứ hạng SEO.
- Đảm bảo lồng ghép các con số vào mỗi câu ở những vị trí phù hợp nhằm tăng tính dễ đọc và hiệu quả SEO.
- Cấu trúc nội dung bằng các thẻ H1 và H2 để tạo sự phân cấp rõ ràng và làm nổi bật trọng tâm.

Quy tắc:
- Tránh nhồi nhét từ khóa quá mức để duy trì sự tự nhiên, dễ đọc cho bài viết.
- Sử dụng các công cụ kiểm tra đạo văn để đảm bảo toàn bộ nội dung đều là bản gốc.
MarketingSEOContent Creation
S@seoshubham608
0
Trình Tạo Caption Instagram Cho Nghệ Sĩ Indie
Văn bản

Viết 3 caption Instagram giúp chuyển đổi người theo dõi thành người hâm mộ sẵn sàng chi tiền. Được thiết kế riêng cho các nghệ sĩ độc lập (indie) bán hàng trực tiếp cho người hâm mộ trên Bandcamp, EVEN.Biz, Bandzoogle, Shopify hoặc trang web riêng của họ: bản tải nhạc số, đĩa than (vinyl), CD, vật phẩm lưu niệm (merch) và các gói combo (bundles). Không chèn liên kết nghe nhạc trực tuyến (streaming). Được phát triển bởi ArtistVault (artistvault.co), Trợ lý Quản lý Nghệ sĩ Tự động.

Hãy đóng vai một chuyên gia chiến lược Instagram dành cho các nghệ sĩ âm nhạc độc lập bán sản phẩm TRỰC TIẾP cho người hâm mộ. Lượt phát trực tuyến (streams) chỉ trả vài phần nhỏ của một xu và không tạo ra sự gắn kết thực sự nào. Nhiệm vụ của bạn là viết những đoạn caption khiến người hâm mộ nhấp vào liên kết và MUA một thứ gì đó từ chính cửa hàng riêng của nghệ sĩ: bản tải nhạc số, đĩa than (vinyl), đĩa CD, băng cassette, áo/phụ kiện lưu niệm (merch), gói sản phẩm (bundle) hoặc quyền truy cập nghe trước (early access). Hãy viết như một người nghệ sĩ thực thụ đang tâm sự với những khán giả thân thiết của mình, chứ không phải giọng điệu của một thương hiệu hay hãng thu âm.

THÔNG TIN NGHỆ SĨ
- Tên nghệ sĩ: artist_name
- Thể loại / phong cách chủ đạo: R&B
- Loại bài đăng: new single release  (các tùy chọn: đĩa đơn mới, ra mắt EP/album, đặt trước, đĩa than/CD vừa về kho, ra mắt merch, gói bundle, phát hành quyền truy cập sớm, hậu trường, buổi thu âm trong phòng thu, buổi diễn trực tiếp, câu chuyện cá nhân, tri ân người hâm mộ, đoạn nhạc nhá hàng / teaser)
- Tên bài hát hoặc dự án: title
- Sản phẩm người hâm mộ có thể mua: digital download and vinyl  (các tùy chọn: bản tải kỹ thuật số, bản phát hành truy cập sớm, đĩa than, CD, băng cassette, áo thun/hoodie, vật phẩm có chữ ký, gói giới hạn, đặt trước, thẻ thành viên fan club, vé xem biểu diễn)
- Mức giá hoặc ưu đãi (tùy chọn): name your price download
- Nơi mua hàng: my store, link in bio  (ví dụ: Bandcamp, EVEN, Bandzoogle, Shopify, trang web của tôi. Không bao giờ dùng trang nghe nhạc trực tuyến.)
- Nội dung hiển thị trong bài đăng (mô tả ảnh/video): visual_description
- Một chi tiết thực tế hoặc câu chuyện đằng sau bài hát/khoảnh khắc này: story_detail
- Giọng điệu: real and confident  (các tùy chọn: hào hứng sôi nổi, trải lòng sâu lắng, hài hước, bí ẩn, biết ơn, chân thật và tự tin)

VIẾT 3 CAPTION, mỗi caption khai thác một góc tiếp cận khác nhau:
1. Caption CÂU CHUYỆN (STORY): mở đầu bằng một câu dẫn (hook) mang tính cá nhân, kể lại một khoảnh khắc có thật ngắn gọn, sau đó mời người hâm mộ sở hữu một phần của kỷ niệm đó.
2. Caption NGẮN GỌN ẤN TƯỢNG (SHORT PUNCHY): tối đa từ 1 đến 3 dòng. Được thiết kế riêng cho Reels. Lời kêu gọi mua hàng (CTA) rõ ràng.
3. Caption SỞ HỮU TRỌN VẸN (OWN IT): giải thích bằng những từ ngữ giản dị về lý do tại sao việc mua trực tiếp lại tuyệt vời hơn nghe trên nền tảng phát trực tuyến (bạn giữ nó mãi mãi, nghệ sĩ thực sự nhận được thù lao xứng đáng, bạn trở thành một phần của câu chuyện) mà không hề tỏ ra trách móc hay cay đắng.

QUY TẮC CHO MỌI CAPTION
- Dòng đầu tiên là câu thu hút (hook). Tự bản thân dòng này phải đủ sức khiến người xem dừng lướt màn hình, bởi Instagram sẽ cắt bớt nội dung sau khoảng 125 ký tự.
- KHÔNG BAO GIỜ điều hướng người hâm mộ đến trang nghe nhạc trực tuyến. Không bao giờ yêu cầu người hâm mộ cày view, thêm vào danh sách phát (playlist) hay "cày nát bài nhạc". Mọi lời kêu gọi hành động (CTA) đều phải hướng tới việc mua hoặc sở hữu sản phẩm từ cửa hàng riêng của nghệ sĩ.
- Sử dụng đúng tên cửa hàng mà nghệ sĩ đã cung cấp trong mục "Nơi mua hàng". Nếu không có thông tin nào được đưa ra, hãy gọi là "cửa hàng của tôi" ("my store").
- Làm cho sản phẩm trở nên đặc biệt: số lượng giới hạn, có chữ ký tay, đợt in đĩa đầu tiên (first press), quyền nghe trước, quà tặng độc quyền hoặc mang đậm dấu ấn cá nhân. Chỉ sử dụng yếu tố khan hiếm nếu đó là sự thật.
- MỘT lời kêu gọi hành động (CTA) rõ ràng duy nhất. Hãy nói chính xác cho họ biết cần làm gì và ở đâu (ví dụ: "Sở hữu ngay bản đĩa than có chữ ký tại cửa hàng của mình nhé. Link ở phần tiểu sử (bio)").
- Viết ở mức độ giản dị, dễ đọc. Câu văn ngắn gọn. Hãy trò chuyện với MỘT người hâm mộ cụ thể, chứ không phải hô hào chung chung với "tất cả mọi người".
- Đưa chi tiết câu chuyện có thật vào bài viết. Sự cụ thể luôn chiến thắng những lời sáo rỗng chung chung.
- Biểu tượng cảm xúc (Emojis): từ 0 đến 3 biểu tượng, chỉ đặt ở nơi thực sự phù hợp.
- Ngắt dòng hợp lý để dễ đọc trên màn hình điện thoại.
- Không van nài, không thổi phồng giả tạo, không dùng ngôn từ quảng cáo doanh nghiệp cứng nhắc.

SAU 3 ĐOẠN CAPTION, HÃY CUNG CẤP THÊM:
- 8 đến 12 hashtag: 3 thẻ thể loại nhạc, 3 thẻ ngách/cộng đồng âm nhạc, 2 đến 3 thẻ địa phương hoặc hội nhóm, và 1 thẻ thương hiệu riêng của nghệ sĩ. Bỏ qua các thẻ quá rộng và chung chung như #music hay #love.
- 1 ý tưởng bình luận ghim (pinned comment) giúp thúc đẩy quyết định mua hàng (ví dụ: "25 đơn hàng đầu tiên sẽ nhận được một bức thư viết tay").
- Dòng chữ thu hút hiển thị trên màn hình (on-screen hook text) tốt nhất (dưới 8 từ) nếu bài đăng là video Reel.
- 1 ý tưởng đăng Story tiếp nối sau đó từ 2 đến 4 tiếng có sử dụng nhãn dán liên kết (link sticker) trỏ thẳng đến sản phẩm.
- 1 tin nhắn cảm ơn mà nghệ sĩ có thể gửi trực tiếp (DM) cho từng người mua để biến họ thành người hâm mộ gắn bó trọn đời.

---
Được thực hiện bởi ArtistVault (artistvault.co), Trợ lý Quản lý Nghệ sĩ Tự động dành cho các nghệ sĩ âm nhạc độc lập.
MarketingMusicContent Creation+2
T@tgainesglobal
0
Trình Cấu trúc Ghi chú Tập Podcast (Show Notes)
Kỹ năng

Biến bản gỡ băng (transcript) podcast thô, các mốc thời gian (timestamps) và ghi chú về khách mời thành bản ghi chú tập (show notes) hoàn chỉnh sẵn sàng xuất bản: tiêu đề tập, tóm tắt chuẩn SEO, các ý chính đắt giá, mốc phân đoạn (chapter markers) cho YouTube, Spotify và Podcasting 2.0, các câu trích dẫn ấn tượng kèm mốc thời gian, tiểu sử khách mời, danh sách liên kết và các bài đăng mạng xã hội. Sử dụng khi người dùng chia sẻ bản gỡ băng và yêu cầu "viết show notes giúp tôi", "tạo phân đoạn chương cho tập này" hoặc "trích xuất câu nói hay từ buổi phỏng vấn này". Đi kèm công cụ tìm chương bằng thư viện chuẩn Python đã được kiểm thử.

---
name: podcast-show-notes-structurer
description: Biến bản gỡ băng (transcript) podcast thô, các mốc thời gian (timestamps) và ghi chú về khách mời thành bản ghi chú tập (show notes) hoàn chỉnh sẵn sàng xuất bản: tiêu đề tập, tóm tắt chuẩn SEO, các ý chính đắt giá, mốc phân đoạn (chapter markers) cho YouTube, Spotify và Podcasting 2.0, các câu trích dẫn ấn tượng kèm mốc thời gian, tiểu sử khách mời, danh sách liên kết và các bài đăng mạng xã hội. Sử dụng khi người dùng chia sẻ bản gỡ băng và yêu cầu "viết show notes giúp tôi", "tạo phân đoạn chương cho tập này" hoặc "trích xuất câu nói hay từ buổi phỏng vấn này". Đi kèm công cụ tìm chương bằng thư viện chuẩn Python đã được kiểm thử.
---

# Trình Cấu trúc Ghi chú Tập Podcast (Show Notes)

Bạn giúp các nhà sản xuất podcast biến một bản gỡ băng lộn xộn thành bản ghi chú tập (show notes) mà người nghe thực sự muốn đọc và các công cụ tìm kiếm có thể dễ dàng tìm thấy. Bạn tìm ra cấu trúc thực sự của cuộc trò chuyện, đặt tên các phân đoạn (chapters) theo đúng cách một người nghe sẽ mô tả chúng, chọn lọc những câu trích dẫn đứng độc lập ngoài ngữ cảnh vẫn trọn vẹn ý nghĩa, và viết phần tóm tắt chỉ trong hai dòng giúp người đọc hiểu ngay tại sao tập podcast này xứng đáng với thời gian của họ. Bạn tuyệt đối không bao giờ tự bịa ra dữ kiện, đường dẫn liên kết hay câu trích dẫn không có trong tài liệu gốc.

## Các tệp trong kỹ năng này

- `scripts/chapterize.py` - đọc các bản gỡ băng có mốc thời gian, định dạng WebVTT, có tiêu đề hoặc văn bản thuần túy, đề xuất điểm bắt đầu của từng chương dựa trên tiêu đề, dấu hiệu nghỉ, các cụm từ chuyển ý ("let's talk about", "after the break") và sự chuyển dịch từ vựng, phác thảo tiêu đề từ khóa, liệt kê các điểm đánh dấu quảng cáo và âm nhạc, đồng thời xếp hạng các câu trích dẫn tiềm năng (chỉ sử dụng thư viện chuẩn Python 3)
- `references/show-notes-structure.md` - nội dung cần có trong từng phần, giới hạn độ dài, cùng cách viết tiêu đề, tóm tắt và các ý chính (key takeaways)
- `references/chapters-and-platforms.md` - quy tắc phân đoạn chương cho YouTube, Spotify, Apple Podcasts và Podcasting 2.0, kèm theo nguyên tắc đạo đức khi trích dẫn và gắn mốc thời gian
- `templates/show-notes.md` - mẫu tài liệu show notes để trả kết quả về
- `examples/example-rooftop-beekeeping-episode.md` - một ví dụ thực tế hoàn chỉnh từ cuộc phỏng vấn dài 25 phút

## Quy trình làm việc

### 1. Thu thập tài liệu
Hỏi người dùng, hoặc đưa ra giả định hợp lý và nêu rõ giả định đó:
- Bản gỡ băng (bất kỳ định dạng nào) và liệu các mốc thời gian trong đó có khớp với bản dựng âm thanh cuối cùng hay không. Nếu phần mở đầu (intro) hoặc quảng cáo được chèn thêm sau khi gỡ băng, hãy lấy độ lệch thời gian (offset) tính bằng giây.
- Tên chương trình, tên người dẫn (host) và khách mời với cách viết chính xác, vai trò của khách mời, cùng các đường dẫn liên kết họ muốn đưa vào (chỉ sử dụng những liên kết do người dùng cung cấp).
- Nơi bản ghi chú sẽ được xuất bản (nền tảng lưu trữ podcast, YouTube, trang web, bản tin newsletter) và bất kỳ quy chuẩn phong cách riêng nào: giọng điệu, độ dài, biểu tượng cảm xúc (emoji), cách diễn đạt cho nhà tài trợ.
- Đối tượng thính giả mục tiêu và 2 hoặc 3 cụm từ tìm kiếm mà người nghe có thể gõ để tìm thấy tập này.

### 2. Chạy công cụ tìm chương
```bash
python3 scripts/chapterize.py transcript.txt
python3 scripts/chapterize.py transcript.txt --min-chapter 3 --offset 45 --duration 52:30
python3 scripts/chapterize.py episode.vtt --format youtube
python3 scripts/chapterize.py - --format json < transcript.txt
```
Các điểm bắt đầu chương và câu trích dẫn do script tạo ra chỉ là các ứng viên gợi ý, không phải kết quả cuối cùng. Hãy đọc kỹ bản gỡ băng xung quanh mỗi điểm bắt đầu được đề xuất và dịch chuyển mốc đó tới đúng câu mà chủ đề mới thực sự bắt đầu. Hãy viết lại mọi tiêu đề dạng từ khóa (ví dụ: "Roof / Hive / Water / Wind") thành tiêu đề ngắn gọn hướng tới người nghe ("Keeping bees on a windy roof" - Nuôi ong trên mái nhà đầy gió). Nếu bản gỡ băng không có mốc thời gian, các con số thời gian chỉ là ước tính (được đánh dấu `~`); hãy nhắc người dùng thay thế chúng bằng mốc thời gian chính xác từ phần mềm dựng âm thanh. Nếu bạn không thể chạy script, hãy tìm các chương thủ công theo cùng nguyên tắc: dựa vào tiêu đề, điểm nghỉ, cụm từ chuyển ý, rồi đến các điểm thay đổi chủ đề.

### 3. Viết ghi chú tập (Show notes)
1. Tiêu đề: dài 40 đến 60 ký tự, nêu bật giá trị hứa hẹn chính của tập, kèm tên khách mời nếu điều đó giúp ích cho việc tìm kiếm.
2. Tóm tắt: 2 câu dùng cho phần xem trước trên ứng dụng (150 ký tự đầu tiên phải tự đứng độc lập và truyền tải trọn ý), tiếp theo là một đoạn văn ngắn.
3. Các ý chính (Key takeaways): 3 đến 5 gạch đầu dòng, mỗi gạch đầu dòng là một ý tưởng cụ thể rút ra từ tập podcast, không phải là tên một chủ đề chung chung.
4. Phân đoạn chương (Chapters): từ 4 đến 10 chương đối với một tập dài 20 đến 60 phút, chương đầu tiên bắt đầu ở 00:00, tiêu đề dưới 40 ký tự, sắp xếp theo đúng trình tự thời gian diễn ra.
5. Trích dẫn (Quotes): 3 đến 5 câu có ý nghĩa trọn vẹn ngay cả khi tách khỏi ngữ cảnh, ghi kèm tên người nói và mốc thời gian. Chỉ lược bỏ các từ đệm; tuyệt đối không làm thay đổi ý nghĩa. Đánh dấu rõ các câu trích dẫn đã biên tập nhẹ để người dùng đối chiếu lại với bản thu âm.
6. Tiểu sử khách mời (2 hoặc 3 câu), các liên kết và tài nguyên được nhắc đến (chỉ đưa các liên kết có thật từ tài liệu gốc hoặc do người dùng cung cấp), thông tin ghi nhận đóng góp (credits), và tuyên bố minh bạch về nhà tài trợ nếu có quảng cáo.
7. Bài đăng mạng xã hội: một bài cho mỗi nền tảng mà người dùng nêu tên, mỗi bài sử dụng một câu trích dẫn hoặc một ý chính đắt giá.

### 4. Bàn giao kết quả
Điền vào biểu mẫu `templates/show-notes.md` đồng thời tuân thủ hướng dẫn trong `references/show-notes-structure.md` và `references/chapters-and-platforms.md`. Trình bày theo phong cách của `examples/example-rooftop-beekeeping-episode.md`. Bổ sung một danh sách ngắn "những mục cần kiểm tra trước khi xuất bản": các mốc thời gian ước tính, các câu trích dẫn đã biên tập, chính tả tên riêng, cùng bất kỳ dữ kiện hay con số nào khách mời đã nêu mà cần được kiểm chứng lại.

## Quy tắc
- Chỉ sử dụng các dữ kiện, tên riêng, con số và liên kết có trong bản gỡ băng hoặc do người dùng cung cấp; gắn thẻ [check] cho bất kỳ thông tin nào chưa rõ ràng.
- Các câu trích dẫn phải là lời nói nguyên văn của người phát biểu; việc lược bỏ các từ đệm như "um", "you know" là chấp nhận được, nhưng diễn đạt lại bằng từ ngữ khác thì không.
- Giữ cho tiêu đề các chương mang tính mô tả và trung thực; không giật tít câu view (clickbait) những điều mà nội dung tập không mang lại.
- Tôn trọng khách mời: không trích dẫn các nhận xét riêng tư, những khoảnh khắc trò chuyện ngoài lề (off-the-record), hoặc bất kỳ phần nào người dùng đã đánh dấu cắt bỏ.
- Hòa hợp với giọng điệu chung của chương trình; mặc định sử dụng văn phong ấm áp, rõ ràng và cụ thể.
FILE:references/show-notes-structure.md
# Cấu trúc bản ghi chú tập (Show notes)

Người nghe thường đọc lướt show notes trong ứng dụng podcast trên điện thoại, các công cụ tìm kiếm đọc chúng trên trang web của tập, và khách mời sẽ chia sẻ chúng. Mỗi phần đều có một nhiệm vụ duy nhất.

| Phần | Nhiệm vụ | Độ dài |
|---|---|---|
| Tiêu đề tập (Episode title) | Nêu bật giá trị hứa hẹn chính; hỗ trợ tìm kiếm | 40 đến 60 ký tự; hầu hết ứng dụng sẽ cắt bớt sau khoảng 60 ký tự |
| Tóm tắt xem trước (Preview summary) | Khiến người dùng nhấn nút phát ngay từ danh sách trên ứng dụng | 150 ký tự đầu tiên phải tự truyền tải trọn ý; tổng cộng 2 câu |
| Tóm tắt đầy đủ (Full summary) | Giải thích khách mời là ai và những nội dung được bàn luận | 60 đến 120 từ |
| Các ý chính (Key takeaways) | Cung cấp giá trị cốt lõi cho người đọc lướt | 3 đến 5 gạch đầu dòng, mỗi dòng một ý tưởng, dưới 20 từ |
| Phân đoạn chương (Chapters) | Giúp người nghe chuyển nhanh đến phần quan tâm | 4 đến 10 chương cho thời lượng 20 đến 60 phút; tiêu đề dưới 40 ký tự |
| Trích dẫn nổi bật (Pull quotes) | Những câu nói đắt giá dùng cho phần ghi chú và mạng xã hội | 3 đến 5 câu, mỗi câu từ 12 đến 30 từ, kèm tên người nói và mốc thời gian |
| Tiểu sử khách mời (Guest bio) | Giới thiệu và ghi nhận khách mời | 2 hoặc 3 câu, viết ở ngôi thứ ba |
| Liên kết và tài nguyên (Links and resources) | Mọi thứ được nhắc đến mà có đường dẫn liên kết | Chỉ đưa những liên kết do người dùng hoặc khách mời cung cấp |
| Ghi nhận và minh bạch tài trợ (Credits and disclosure) | Người dẫn, khách mời, nhà sản xuất, âm nhạc, nhà tài trợ | Mỗi mục một dòng |
| Lời kêu gọi hành động (Call to action) | Một bước tiếp theo duy nhất | Đăng ký theo dõi, đánh giá, nhận bản tin, hoặc một câu hỏi dành cho người nghe |

## Tiêu đề

- Mở đầu bằng ý tưởng cốt lõi chứ không phải số thứ tự tập: "Keeping Bees on a Hotel Roof with Daniel Okafor" (Nuôi ong trên mái khách sạn cùng Daniel Okafor) hiệu quả hơn hẳn "Ep. 48: Daniel Okafor".
- Sử dụng những từ ngữ mà người nghe thực sự sẽ gõ tìm kiếm: "rooftop beekeeping" (nuôi ong trên sân thượng), "city honey" (mật ong phố thị).
- Tránh đặt những câu hỏi mà tập podcast không giải đáp và những lời hứa hẹn mà khách mời không hề đưa ra.

## Tóm tắt

- Câu 1: ai và nói về điều gì, bằng ngôn từ giản dị. Câu 2: điểm bất ngờ nhất hoặc hữu ích nhất.
- Viết cho con người đọc chứ không phải để nhồi nhét từ khóa; lồng ghép tự nhiên 2 hoặc 3 cụm từ tìm kiếm, mỗi cụm một lần.
- Không dùng lối mở đầu sáo rỗng "Trong tập này chúng ta sẽ nói về..."; hãy đi thẳng vào nội dung thực chất.

## Các ý chính (Key takeaways)

Hãy viết mỗi ý chính dưới dạng một nhận định hoặc hướng dẫn cụ thể rút ra từ tập podcast, chứ không phải tên một chủ đề:
- Yếu: "Nước trên mái nhà."
- Mạnh: "Hãy đặt một khay nước nông cho đàn ong trên sân thượng, nếu không chúng sẽ tìm đến bể bơi của hàng xóm."

## Tiêu đề chương

- Mô tả những gì người nghe sẽ nhận được trong phần đó chỉ bằng 2 đến 6 từ: "Tại sao gió mới là kẻ thù thực sự".
- Giữ cấu trúc ngữ pháp song song đồng nhất (tất cả cùng là câu khẳng định hoặc tất cả cùng là câu hỏi).
- Chỉ đặt tên cho các đoạn nghỉ quảng cáo nếu nền tảng không tự động ẩn chúng; tuyệt đối không gắn nhãn một đoạn quảng cáo như thể đó là một chương nội dung.

## Trích dẫn

- Chọn những câu có thể đứng độc lập: một nhận định rõ ràng, một bài học, một con số, hoặc một hình ảnh sống động.
- Tránh chọn những lời chào hỏi, câu hỏi dẫn dắt, và những câu cần phải đọc câu trước đó mới hiểu được.
- Việc lược bỏ các từ đệm (um, uh, you know) là hoàn toàn ổn; hãy đánh dấu câu trích dẫn là đã biên tập nhẹ. Tuyệt đối không được tự ý thay đổi từ ngữ.

## Khả năng tiếp cận và độ tin cậy

- Đính kèm liên kết dẫn tới toàn bộ bản gỡ băng (transcript) khi có sẵn; điều này giúp ích cho người nghe khiếm thính hoặc nghe kém cũng như hỗ trợ công cụ tìm kiếm.
- Công bố rõ ràng các nhà tài trợ và các liên kết tiếp thị liên kết (affiliate links).
- Đặt thẻ [check] vào bất kỳ con số, tên riêng hoặc nhận định nào cần được xác minh lại trước khi xuất bản.
FILE:references/chapters-and-platforms.md
# Phân đoạn chương và quy tắc của các nền tảng

Các quy tắc có thể thay đổi theo thời gian; khi nghi ngờ, hãy kiểm tra trang trợ giúp hiện hành của từng nền tảng. Dưới đây là những nguyên tắc cơ bản ổn định nhất.

## YouTube (podcast video và tệp âm thanh tải lên)

- Các chương được tạo từ các mốc thời gian trong phần mô tả, mỗi chương trên một dòng: `00:00 Intro`.
- Mốc thời gian đầu tiên bắt buộc phải là 00:00.
- Phải có ít nhất 3 mốc thời gian, và mỗi chương phải dài tối thiểu 10 giây.
- Sử dụng định dạng `MM:SS` cho các video dưới một giờ và `H:MM:SS` một khi có bất kỳ chương nào vượt quá mốc một giờ.
- Lệnh `chapterize.py --format youtube` sẽ in các dòng theo đúng định dạng này và cảnh báo khi có quy tắc bị vi phạm.

## Spotify

- Spotify đọc các mốc thời gian chương từ phần mô tả tập theo cùng kiểu `MM:SS Title` đối với các tập trên nền tảng của mình; hãy tuân thủ các quy tắc tương tự như YouTube (bắt đầu tại 00:00, cách nhau vài phút).

## Apple Podcasts và hầu hết các ứng dụng khác

- Các chương được nhúng trực tiếp vào tệp âm thanh: các khung ID3 CHAP trong tệp MP3 hoặc một rãnh chương (chapter track) trong tệp M4A/AAC. Hãy thêm chúng trong phần mềm dựng âm thanh hoặc công cụ gắn thẻ (tagging tool) trước khi tải lên.
- Apple có thể hiển thị các chương được tạo tự động khi một tập không có chương nào; việc tự cung cấp chương riêng giúp bạn giữ quyền kiểm soát các tiêu đề.

## Podcasting 2.0 (RSS)

- Thẻ `<podcast:chapters>` trỏ tới một tệp JSON chứa danh sách chương, được hỗ trợ bởi nhiều ứng dụng và dịch vụ lưu trữ (host) hiện đại:
```json
{
  "version": "1.2.0",
  "chapters": [
    { "startTime": 0, "title": "Intro" },
    { "startTime": 280, "title": "Keeping bees on a windy roof" }
  ]
}
```
- `startTime` được tính bằng giây; lệnh `chapterize.py --format json` trả về giá trị `start` tính bằng giây cho từng chương.

## Mốc thời gian (Timestamps)

- Thời gian trong bản gỡ băng thường lệch so với tệp âm thanh cuối cùng: nhạc mở đầu, quảng cáo và việc cắt dựng sẽ làm dịch chuyển chúng. Hãy sử dụng `--offset` cho một khoảng dịch chuyển cố định và kiểm tra lại một vài chương trong tệp âm thanh.
- Khi không có mốc thời gian, `chapterize.py` sẽ ước tính thời gian dựa trên số lượng từ (mặc định 150 từ mỗi phút) và đánh dấu chúng bằng ký hiệu `~`. Tuyệt đối không xuất bản các mốc thời gian ước tính mà chưa kiểm tra lại.
- Cơ chế chèn quảng cáo động (dynamic ad insertion) làm thay đổi thời gian tùy theo từng người nghe; chỉ đưa các chương vào tệp hoặc phần mô tả khi quảng cáo đã được ghép cứng (baked in) vào âm thanh hoặc khi nền tảng lưu trữ hỗ trợ tự động điều chỉnh mốc chương.

## Cách script tìm điểm bắt đầu chương

1. Các dòng tiêu đề (`# Title`, `Chapter 3: ...`, `Segment: ...`) luôn bắt đầu một chương mới.
2. Các dấu hiệu nghỉ (`[music]`, `[ad break]`, `[pause]`, `***`, `---`) đánh dấu khối lời thoại tiếp theo là một ứng viên bắt đầu chương.
3. Các cụm từ chuyển phần được chấm điểm cao ("let's talk about", "moving on", "switching gears", "welcome back", "before we wrap up"); các cụm từ chuyển câu hỏi đơn lẻ ("next question") được chấm điểm thấp hơn để giữ cho toàn bộ phần hỏi đáp (Q&A) nằm gọn trong một chương.
4. Sự chuyển dịch từ vựng: mức độ khác biệt của các từ ngữ trong 3 khối lời thoại trước và sau một thời điểm.
5. Các điểm bắt đầu cách nhau gần hơn `--min-chapter` phút sẽ bị loại bỏ (ngoại trừ các dòng tiêu đề), và không có chương nào được bắt đầu trong phút cuối cùng.

## Đạo đức khi trích dẫn và sử dụng bản gỡ băng

- Giữ nguyên sự trung thực của các câu trích dẫn; đánh dấu việc lược bỏ từ đệm là đã qua biên tập.
- Xin phép trước khi trích dẫn câu chuyện cá nhân của khách mời lên mạng xã hội nếu đó là nội dung nhạy cảm.
- Không xuất bản những nhận xét mà người dùng đã đánh dấu là trò chuyện ngoài lề (off the record) hoặc đã bị cắt khỏi tập podcast.
FILE:templates/show-notes.md
# episode_title

**show_name** | Tập episode_number | duration | Khách mời: guest_name

preview_summary_two_sentences

full_summary_paragraph

## Các ý chính (Key takeaways)

- 
- 
- 

## Phân đoạn chương (Chapters)

```
00:00 chapter_1
time chapter_2
```

## Trích dẫn nổi bật (Quotes)

> "quote" - speaker (time)

## Giới thiệu về guest_name

guest_bio

## Liên kết và tài nguyên được nhắc đến

- resource - url_or_check

## Ghi nhận đóng góp (Credits)

Dẫn chương trình: host | Khách mời: guest_name | Sản xuất: producer | Âm nhạc: music_credit
sponsor_disclosure

call_to_action

---

## Bài đăng mạng xã hội

- **platform:** post_with_quote_or_takeaway

## Kiểm tra trước khi xuất bản

- [ ] Đã đối chiếu mốc thời gian các chương với tệp âm thanh cuối cùng (độ lệch offset: offset)
- [ ] Đã kiểm tra các câu trích dẫn qua biên tập với tệp âm thanh
- [ ] Đã xác nhận tên riêng và chính tả
- [ ] Đã xác minh các dữ kiện và con số được gắn thẻ [check]
- [ ] Đã kiểm tra hoạt động của các đường dẫn liên kết
FILE:examples/example-rooftop-beekeeping-episode.md
# Ví dụ: Bản ghi chú tập (show notes) cho cuộc phỏng vấn dài 25 phút

## Yêu cầu của người dùng

"Đây là bản gỡ băng của tập 48 chương trình Small Plot Radio trò chuyện cùng Daniel Okafor, một người nuôi ong trên mái khách sạn. Chúng tôi đã chèn thêm một đoạn nhạc mở đầu dài 45 giây trong lúc dựng, nên tệp âm thanh cuối cùng có thời lượng 26:02. Bạn có thể viết giúp tôi bản show notes kèm các phân đoạn chương cho YouTube và Spotify, một vài câu trích dẫn hay, và một bài đăng Instagram được không? Các buổi tham quan trên mái khách sạn và khóa học của Daniel đều có liên kết trên trang web của họ; tôi sẽ tự điền chúng vào sau."

## Chạy công cụ tìm chương

```
$ python3 scripts/chapterize.py rooftop-bees.txt
Transcript: 1140 words, about 25:17 long, timestamps found, speakers: Daniel (20 turns), Maya (19 turns)

Chapter candidates (6, min 4 min apart):
   1.     00:00    4.7 min  [detected] Hotel Manager / Hive / Twelve / Bees
   2.     04:40    4.5 min  [detected] Roof / Hive / Water / Wind
   3.     09:12    5.3 min  [detected] Honey Tour / Spring Honey / Late Summer / Guests
   4.     14:30    5.0 min  [detected] City / Bees / Honey / Roof
   5.     19:30    4.7 min  [detected] Hive / Balcony / Bees / Priya
   6.     24:10    1.1 min  [detected] Keeping / Colony / Association / Find

Markers (breaks, music, ads): 13:30 [ad break]

Quote candidates (6):
     00:36  Daniel: "I built my first hive from a kit that winter and I made every beginner mistake you can imagine."
     01:24  Daniel: "The biggest lesson I learned that first year was that good beekeeping is mostly patience."
     05:33  Daniel: "If you don't give them water, they will find the hotel pool or the neighbors' air conditioning drip, and that is how complaints start."
     ...
$ python3 scripts/chapterize.py rooftop-bees.txt --offset 45 --format youtube
00:00 Hotel Manager / Hive / Twelve / Bees
05:25 Roof / Hive / Water / Wind
09:57 Honey Tour / Spring Honey / Late Summer / Guests
15:15 City / Bees / Honey / Roof
20:15 Hive / Balcony / Bees / Priya
24:55 Keeping / Colony / Association / Find
# note: YouTube needs the first chapter at 00:00; the first one was moved to 00:00
```

Đánh giá các ứng viên chương: cả sáu điểm bắt đầu đều rơi đúng vào các câu chuyển ý thực sự ("Let's talk about the roof itself", "Switching gears to the hotel manager", "Welcome back", "Let's move on to listener questions", "Before we wrap up"). Đoạn nghỉ quảng cáo ở phút 13:30 được giữ nguyên bên trong chương 3 vì quảng cáo đã được ghép cứng vào tệp âm thanh. Chương 1 khá dài nên được tách thủ công tại mốc 01:20 ("What was the biggest mistake?"), trở thành 02:05 sau khi cộng độ lệch thời gian (offset). Mọi tiêu đề dạng từ khóa đều đã được viết lại.

## Bản ghi chú tập (mẫu đã điền)

# Keeping Bees on a Hotel Roof with Daniel Okafor (Nuôi ong trên mái khách sạn cùng Daniel Okafor)

**Small Plot Radio** | Tập 48 | 26:02 | Khách mời: Daniel Okafor

Daniel Okafor nuôi mười hai thùng ong trên mái một khách sạn ở trung tâm thành phố. Anh chia sẻ cách khắc phục gió lớn, nắng nóng và cơn khát cho đàn ong trên mái nhà, cũng như lý do vì sao việc trồng hoa lại giúp ích cho các loài thụ phấn nhiều hơn là đặt thêm thùng ong.

Chín năm trước, một đàn ong di cư đã đáp xuống ban công nhà Daniel; ngày nay anh vận hành mười hai thùng ong trên mái một khách sạn, cung cấp mật ong cho gian bếp khách sạn và dẫn dắt các tour tham quan mật ong trên sân thượng luôn kín chỗ. Maya và Daniel trò chuyện về những sai lầm của người mới bắt đầu, hàng rào chắn gió, khay nước, tải trọng mái nhà, cách anh thuyết phục người quản lý khách sạn, liệu mật ong thành phố có an toàn để thưởng thức hay không, và tại sao việc đặt quá nhiều thùng ong trong thành phố có thể lấn át không gian sống của các loài ong hoang dã. Cuối chương trình, họ giải đáp các câu hỏi của thính giả về việc nuôi ong ngoài ban công, dụng cụ khởi đầu và sản lượng mật thu hoạch.

## Các ý chính (Key takeaways)

- Kiểm tra thùng ong định kỳ mỗi 7 đến 10 ngày vào mùa xuân theo kế hoạch rõ ràng, và để chúng yên ổn trong khoảng thời gian giữa các lần kiểm tra.
- Trên mái nhà, gió là kẻ thù chính: một hàng rào chắn gió dạng nan cao 1.5 m ở các mặt đón gió sẽ tạo ra sự khác biệt lớn.
- Hãy đặt cho đàn ong trên sân thượng một khay nước nông có rải sỏi, nếu không chúng sẽ tìm đến bể bơi của hàng xóm.
- Cần kiểm tra tải trọng mái nhà trước tiên: một thùng ong đầy có thể nặng từ 80 đến 100 kg.
- Việc trồng thêm hoa giúp ích cho các loài thụ phấn nhiều hơn là đặt thêm một thùng ong nữa vào một thành phố vốn đã chật kín.

## Phân đoạn chương (Chapters)

```
00:00 Cơ duyên một đàn ong biến Daniel thành người nuôi ong
02:05 Sai lầm của người mới: mở thùng ong quá thường xuyên
05:25 Nuôi ong trên mái nhà đầy gió
09:57 Thuyết phục khách sạn (và cả gian bếp của họ)
15:15 Mật ong thành phố có an toàn không, và liệu đã có quá nhiều thùng ong?
20:15 Câu hỏi từ thính giả: ban công, dụng cụ và sản lượng mật
24:55 Điều duy nhất mà mọi người mới nuôi ong đều nên biết
```

## Trích dẫn nổi bật (Quotes)

> "Bài học lớn nhất tôi nhận ra trong năm đầu tiên đó là nghề nuôi ong giỏi phần lớn nằm ở sự kiên nhẫn." - Daniel Okafor (02:09)

> "Nếu bạn không cung cấp nước cho chúng, chúng sẽ tìm đến bể bơi khách sạn hoặc ống nhỏ giọt điều hòa của hàng xóm, và đó chính là lúc những lời phàn nàn bắt đầu xuất hiện." - Daniel Okafor (06:18)

> "Nếu bạn muốn giúp các loài thụ phấn, việc trồng hoa có ý nghĩa hơn nhiều so với việc đặt thêm một thùng ong nữa." - Daniel Okafor (16:53)

## Giới thiệu về Daniel Okafor

Daniel Okafor là một người nuôi ong đô thị với chín năm kinh nghiệm và hiện đang chăm sóc mười hai thùng ong trên mái một khách sạn ở trung tâm thành phố. Anh dẫn dắt các tour tham quan mật ong trên sân thượng hàng tháng của khách sạn và giảng dạy khóa học dành cho người mới bắt đầu tại hiệp hội nuôi ong thành phố vào mỗi mùa xuân.

## Liên kết và tài nguyên được nhắc đến

- Tour tham quan mật ong trên sân thượng khách sạn - [liên kết từ người dùng]
- Khóa học nuôi ong cho người mới bắt đầu, hiệp hội nuôi ong thành phố - [liên kết từ người dùng]
- Nhà cho ong bầu (Mason bee) đặt ngoài ban công (được nhắc đến ở phút 20:15) - không có liên kết

## Ghi nhận đóng góp (Credits)

Dẫn chương trình: Maya | Khách mời: Daniel Okafor | Sản xuất: [check] | Âm nhạc: [check]
Tập này có bao gồm một đoạn quảng cáo tại mốc 14:15. [check tên nhà tài trợ để công bố minh bạch]

Bạn có câu hỏi nào về việc nuôi ong ở ban công hay trên mái nhà muốn gửi tới Daniel không? Hãy gửi về cho chúng tôi và chúng tôi sẽ giải đáp trong một tập sắp tới.

---

## Bài đăng mạng xã hội

- **Instagram:** "Việc trồng hoa có ý nghĩa hơn nhiều so với việc đặt thêm một thùng ong nữa." Daniel Okafor đang nuôi mười hai thùng ong trên mái một khách sạn, và lời khuyên của anh nhằm giúp đỡ các loài thụ phấn nơi đô thị có thể sẽ khiến bạn bất ngờ. Tập mới của Small Plot Radio đã lên sóng, liên kết nằm trong phần tiểu sử (bio).

## Kiểm tra trước khi xuất bản

- [ ] Đã đối chiếu mốc thời gian các chương với tệp âm thanh cuối cùng (độ lệch offset: cộng thêm +45 giây vào mọi mốc thời gian trong bản gỡ băng)
- [ ] Đã kiểm tra các câu trích dẫn với tệp âm thanh (không có câu nào bị chỉnh sửa)
- [ ] Đã xác nhận cách đánh vần tên "Okafor" với khách mời
- [ ] Các dữ kiện cần xác minh: "không có vị khách nào bị ong đốt trong suốt chín mùa", kết quả kiểm nghiệm phòng thí nghiệm hàng năm đều dưới ngưỡng giới hạn, sản lượng 15 đến 30 kg mỗi thùng
- [ ] Đã thêm và kiểm tra hoạt động của các đường dẫn liên kết
FILE:scripts/chapterize.py
#!/usr/bin/env python3
"""Split a podcast transcript into rough chapter candidates and pull quote candidates.

Usage:
  python3 chapterize.py TRANSCRIPT [options]
  python3 chapterize.py - < transcript.txt          (read stdin)

Accepted input (detected automatically, can be mixed):
  - timestamp lines: "[00:12:30] Ana: text", "00:12:30 text", "(12:30) text", "12:30 - text"
  - WebVTT/SRT cue times: "00:01:02.000 --> 00:01:05.000" (the cue text follows)
  - headings: "# Intro", "## Part 2", "Chapter 3: Pricing", "SEGMENT: listener questions"
  - pause markers: [pause], [music], [break], [ad break], (long pause), ***, ---
  - plain paragraphs separated by blank lines (times are estimated from --wpm)

Options:
  --min-chapter MIN   shortest chapter in minutes (default 4)
  --max-chapters N    most chapters to return (default 12)
  --wpm N             speaking rate used to estimate times without timestamps (default 150)
  --offset SEC        seconds to add to every time, for an intro added in the edit (default 0)
  --quotes N          number of quote candidates (default 6)
  --format FMT        text (default), json, or youtube (chapter lines "00:00 Title")
  --duration HH:MM:SS total episode length, to check the last chapter and YouTube rules

Chapter boundaries come from headings first, then pause markers and cue phrases
("let's talk about", "moving on", "after the break"), then vocabulary shifts between
neighbouring blocks. Titles are keyword guesses: always rewrite them for the listener.
Exit codes: 0 ok, 1 input too short for chapters, 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import sys
from collections import Counter

TS_LINE = re.compile(r"^\s*[\[(]?((?:\d{1,2}:)?\d{1,2}:\d{2})(?:[.,]\d+)?[\])]?\s*(?:[-\u2013\u2014|:]\s*)?(.*)$")
CUE_LINE = re.compile(r"^\s*((?:\d{1,2}:)?\d{1,2}:\d{2})[.,]\d{1,3}\s*-->\s*((?:\d{1,2}:)?\d{1,2}:\d{2})?")
LAST_CUE_END = [0]
HEADING = re.compile(r"^\s*(?:#{1,6}\s+(.+)|(?:chapter|part|segment|section|topic)\s*\d*\s*[:.\-\u2013]\s*(.+))\s*$", re.I)
PAUSE = re.compile(r"^\s*(?:\[(?:pause|long pause|music|break|ad break|ad|sponsor|interlude|silence)[^\]]*\]|"
                   r"\((?:pause|long pause|music|break|ad break|silence)[^)]*\)|\*{3,}|-{3,}|={3,})\s*$", re.I)
SPEAKER = re.compile(r"^([A-Z][\w.'-]*(?: [A-Z][\w.'-]*){0,2}):\s+(.*)$")
# section-level cue phrases start a new chapter; item-level ones (single questions) only nudge
CUES = ["let's talk about", "lets talk about", "let's move on", "moving on", "switching gears", "change gears",
        "after the break", "welcome back", "before we wrap", "to wrap up", "let's get into", "let's dive into",
        "let's start with", "rapid fire", "listener questions"]
ITEM_CUES = ["next question", "first question", "last question", "final question", "the next thing",
             "another thing i want", "speaking of"]
INTRO = re.compile(r"\b(welcome (to|back)|my guest|today i'm talking|today i am talking|thanks for (having|listening)|"
                   r"see you (next|in)|where can people find)\b", re.I)
STOP = set("""a about above after again against all also am an and any are aren't as at be because been before being
below between both but by can can't could couldn't did didn't do does doesn't doing don't down during each even ever
every few for from further get gets getting go going gonna got had hadn't has hasn't have haven't having he he'd he'll
he's her here here's hers herself him himself his how how's i i'd i'll i'm i've if in into is isn't it it's its itself
just know kind kinda let let's like lot really right said say says see so some such sort than that that's the their
theirs them themselves then there there's these they they'd they'll they're they've thing things think this those
though through to too um uh under until up us very want was wasn't way we we'd we'll we're we've well were weren't what
what's when when's where where's which while who who's whom why why's will with won't would wouldn't yeah yes you you'd
you'll you're you've your yours yourself yourselves okay ok actually basically maybe mean pretty probably stuff sure
talk talking yeah one two three lot lots much many more most other others same back new good great time people
something anything everything nothing make made making take took come came look looking need needs needed thank
thanks today episode podcast show welcome not first question questions listener listeners she she's she'd she'll now""".split())
QUOTE_WORDS = re.compile(r"\b(never|always|nobody|everyone|the secret|the key|the biggest|the hardest|the best|the worst|"
                         r"mistake|learned|lesson|truth|matters|changed|surprised|realized|if you|you have to|don't)\b", re.I)
FILLER = re.compile(r"\b(um+|uh+|you know|i mean|sort of|kind of|like,)\s*", re.I)


class UsageError(Exception):
    pass


def to_sec(ts):
    parts = [int(p) for p in ts.split(":")]
    while len(parts) < 3:
        parts.insert(0, 0)
    h, m, s = parts
    return h * 3600 + m * 60 + s


def fmt(sec, force_hours=False):
    sec = int(round(sec))
    h, rem = divmod(sec, 3600)
    m, s = divmod(rem, 60)
    return f"{h:d}:{m:02d}:{s:02d}" if h or force_hours else f"{m:02d}:{s:02d}"


def words(text):
    return re.findall(r"[A-Za-z][A-Za-z'-]+", text)


def parse(text):
    """Return a list of units: dict(kind=text|heading|pause, text, time or None, speaker)."""
    units, pending_time = [], None
    lines = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff").split("\n")
    para = []

    def flush():
        if para:
            joined = " ".join(p["text"] for p in para)
            units.append({"kind": "text", "text": joined, "time": para[0]["time"], "speaker": para[0]["speaker"]})
            para.clear()

    for raw in lines:
        line = raw.strip()
        if not line or line.upper() == "WEBVTT" or line.isdigit():
            if not line:
                flush()
            continue
        m = CUE_LINE.match(line)
        if m:
            pending_time = to_sec(m.group(1))
            if m.group(2):
                LAST_CUE_END[0] = max(LAST_CUE_END[0], to_sec(m.group(2)))
            continue
        h = HEADING.match(line)
        if h:
            flush()
            units.append({"kind": "heading", "text": (h.group(1) or h.group(2)).strip(), "time": pending_time,
                          "speaker": None})
            continue
        if PAUSE.match(line):
            flush()
            units.append({"kind": "pause", "text": line, "time": pending_time, "speaker": None})
            continue
        t = TS_LINE.match(line)
        time = pending_time
        if t and t.group(2) is not None and (":" in t.group(1)):
            time, line = to_sec(t.group(1)), t.group(2).strip()
            flush()
            if not line:
                pending_time = time
                continue
            h = HEADING.match(line)
            if h:
                units.append({"kind": "heading", "text": (h.group(1) or h.group(2)).strip(), "time": time, "speaker": None})
                pending_time = None
                continue
            if PAUSE.match(line):
                units.append({"kind": "pause", "text": line, "time": time, "speaker": None})
                pending_time = None
                continue
        sp = SPEAKER.match(line)
        speaker = None
        if sp and len(sp.group(1)) <= 30:
            speaker, line = sp.group(1), sp.group(2)
            flush()
        if time is not None or pending_time is not None:
            flush()
        para.append({"text": line, "time": time, "speaker": speaker})
        pending_time = None
    flush()
    return units


def assign_times(units, wpm, offset):
    """Fill missing times by interpolating between known ones or from the word count."""
    known = [i for i, u in enumerate(units) if u["time"] is not None]
    clock = 0.0
    timed = bool(known)
    for i, u in enumerate(units):
        if u["time"] is not None:
            clock = float(u["time"])
            u["estimated"] = False
        else:
            u["time"] = clock
            u["estimated"] = True
        clock = u["time"] + len(words(u["text"])) / wpm * 60 if u["kind"] == "text" else u["time"]
    for u in units:
        u["time"] += offset
    return timed


EXTRA_STOP = set()


def keywords(text, n=4):
    ws = [w.lower().strip("'-") for w in words(text)]
    ws = [w for w in ws if len(w) > 2 and w not in STOP and w not in EXTRA_STOP]
    counts = Counter(ws)
    bigrams = Counter(f"{a} {b}" for a, b in zip(ws, ws[1:]) if a != b)
    out = [b for b, c in bigrams.most_common(3) if c >= 2]
    for w, _c in counts.most_common(12):
        if len(out) >= n:
            break
        if not any(w in b.split() for b in out):
            out.append(w)
    return out[:n]


def similarity(a, b):
    wa = {w.lower() for w in words(a) if w.lower() not in STOP and len(w) > 2}
    wb = {w.lower() for w in words(b) if w.lower() not in STOP and len(w) > 2}
    if not wa or not wb:
        return 0.0
    return len(wa & wb) / len(wa | wb)


def boundaries(units, min_gap, max_chapters, end_time):
    """Score each unit as a chapter start; return chosen indexes (always includes the first text unit)."""
    scores = {}
    for i, u in enumerate(units):
        if u["kind"] == "heading":
            scores[i] = 10.0
        elif u["kind"] == "pause":
            nxt = next((j for j in range(i + 1, len(units)) if units[j]["kind"] == "text"), None)
            if nxt is not None:
                scores[nxt] = max(scores.get(nxt, 0), 3.0)
        elif u["kind"] == "text":
            low = u["text"].lower()
            cue = 4.0 if any(c in low[:160] for c in CUES) else 1.5 if any(c in low[:160] for c in ITEM_CUES) else 0.0
            before = " ".join(x["text"] for x in units[max(0, i - 3):i] if x["kind"] == "text")
            after = " ".join(x["text"] for x in units[i:i + 3] if x["kind"] == "text")
            shift = 1.0 - similarity(before, after) if before and after else 0.0
            score = shift * 2.0 + cue
            if score > 1.6:
                scores[i] = max(scores.get(i, 0), score)
    first = next((i for i, u in enumerate(units) if u["kind"] in ("text", "heading")), 0)
    chosen = [first]
    for i in sorted(scores, key=lambda k: (-scores[k], k)):
        if len(chosen) >= max_chapters:
            break
        t = units[i]["time"]
        forced = units[i]["kind"] == "heading"
        strong = forced or scores[i] >= 4.0  # heading or cue phrase
        if end_time - t < (60 if strong else min_gap / 2) and not forced:
            continue  # too close to the end of the episode
        if all(abs(t - units[c]["time"]) >= min_gap for c in chosen) or (forced and i not in chosen and
                                                                           all(abs(t - units[c]["time"]) >= 10 for c in chosen)):
            chosen.append(i)
    return sorted(set(chosen)), scores


def sentences(text):
    return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]


def quote_candidates(units, n):
    cands = []
    for u in units:
        if u["kind"] != "text":
            continue
        for s in sentences(u["text"]):
            clean = re.sub(r"\s{2,}", " ", FILLER.sub("", s)).strip()
            wc = len(clean.split())
            if wc < 8 or wc > 40 or clean.endswith("?"):
                continue
            score = 0.0
            score += 2.0 if 12 <= wc <= 28 else 0.5
            score += 1.5 * len(QUOTE_WORDS.findall(clean))
            score += 1.0 if re.search(r"\b\d+(?:[.,]\d+)?\s*(%|percent|x|times|years|hours|days)\b", clean, re.I) else 0
            score += 0.5 if re.search(r"\b(I|we)\b", clean) else 0
            score -= 1.0 if FILLER.search(s) else 0
            score -= 3.0 if INTRO.search(clean) else 0
            cands.append({"time": u["time"], "speaker": u["speaker"], "text": clean, "score": round(score, 1),
                          "edited": clean != s})
    cands.sort(key=lambda c: (-c["score"], c["time"]))
    out, seen = [], set()
    for c in cands:
        key = c["text"][:40].lower()
        if key in seen:
            continue
        seen.add(key)
        out.append(c)
        if len(out) >= n:
            break
    return sorted(out, key=lambda c: c["time"])


def build(units, chosen, end_time):
    chapters = []
    for k, start in enumerate(chosen):
        stop = chosen[k + 1] if k + 1 < len(chosen) else len(units)
        seg = units[start:stop]
        text = " ".join(u["text"] for u in seg if u["kind"] == "text")
        heading = next((u["text"] for u in seg[:1] if u["kind"] == "heading"), None)
        t0 = units[start]["time"]
        t1 = units[stop]["time"] if stop < len(units) else end_time
        speakers = Counter(u["speaker"] for u in seg if u["speaker"])
        first = sentences(text)[:1]
        chapters.append({
            "n": k + 1, "start": round(t0), "start_hms": fmt(t0), "end": round(t1), "minutes": round((t1 - t0) / 60, 1),
            "estimated_time": any(u.get("estimated") for u in seg[:1]),
            "source": "heading" if heading else "detected",
            "title_draft": heading or " / ".join(keywords(text)).title() or "(untitled)",
            "keywords": keywords(text, 6), "words": len(words(text)),
            "speakers": [s for s, _ in speakers.most_common()],
            "opening_line": first[0][:160] if first else "",
        })
    return chapters


def youtube_lines(chapters):
    lines, problems = [], []
    if chapters and chapters[0]["start"] != 0:
        problems.append("YouTube needs the first chapter at 00:00; the first one was moved to 00:00")
    for c in chapters:
        start = 0 if c is chapters[0] else c["start"]
        lines.append(f"{fmt(start, force_hours=chapters[-1]['start'] >= 3600)} {c['title_draft']}")
    if len(chapters) < 3:
        problems.append("YouTube shows chapters only with at least 3 of them")
    short = [c["n"] for c in chapters if c["minutes"] * 60 < 10]
    if short:
        problems.append(f"chapters {short} are shorter than 10 seconds (YouTube minimum)")
    return lines, problems


def parse_args(argv):
    opts = {"file": None, "min_chapter": 4.0, "max_chapters": 12, "wpm": 150.0, "offset": 0.0, "quotes": 6,
            "format": "text", "duration": None}
    numeric = {"--min-chapter": ("min_chapter", float), "--max-chapters": ("max_chapters", int),
               "--wpm": ("wpm", float), "--offset": ("offset", float), "--quotes": ("quotes", int)}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ("-h", "--help"):
            print(__doc__)
            sys.exit(0)
        if a in numeric or a in ("--format", "--duration"):
            if i + 1 >= len(argv):
                raise UsageError(f"{a} needs a value")
            v = argv[i + 1]
            if a == "--format":
                if v not in ("text", "json", "youtube"):
                    raise UsageError("--format must be text, json, or youtube")
                opts["format"] = v
            elif a == "--duration":
                if not re.fullmatch(r"(?:\d{1,2}:)?\d{1,2}:\d{2}", v):
                    raise UsageError("--duration must look like 52:30 or 1:05:00")
                opts["duration"] = to_sec(v)
            else:
                key, typ = numeric[a]
                try:
                    opts[key] = typ(v)
                except ValueError:
                    raise UsageError(f"{a} needs a number, got {v!r}")
                if opts[key] < 0 or (key in ("wpm", "max_chapters") and opts[key] == 0):
                    raise UsageError(f"{a} must be positive")
            i += 2
        elif a.startswith("--"):
            raise UsageError(f"unknown option {a}")
        elif opts["file"] is None:
            opts["file"] = a
            i += 1
        else:
            raise UsageError(f"unexpected argument {a!r}")
    if opts["file"] is None:
        raise UsageError("give a transcript file, or - for stdin")
    return opts


def main(argv):
    try:
        opts = parse_args(argv)
        if opts["file"] == "-":
            text = sys.stdin.read()
        else:
            with open(opts["file"], encoding="utf-8") as f:
                text = f.read()
    except UsageError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    except (OSError, UnicodeDecodeError) as e:
        print(f"error: cannot read {opts['file']}: {getattr(e, 'strerror', None) or e}", file=sys.stderr)
        return 2
    units = parse(text)
    total_words = sum(len(words(u["text"])) for u in units if u["kind"] == "text")
    if total_words < 50:
        print(f"error: only {total_words} words of transcript; need at least 50 to suggest chapters", file=sys.stderr)
        return 1
    timed = assign_times(units, opts["wpm"], opts["offset"])
    last = units[-1]
    end_time = last["time"] + (len(words(last["text"])) / opts["wpm"] * 60 if last["kind"] == "text" else 0)
    end_time = max(end_time, LAST_CUE_END[0] + opts["offset"] if LAST_CUE_END[0] else 0)
    if opts["duration"]:
        end_time = max(end_time, opts["duration"])
    EXTRA_STOP.update(w.lower() for u in units if u["speaker"] for w in u["speaker"].split())
    chosen, _scores = boundaries(units, opts["min_chapter"] * 60, opts["max_chapters"], end_time)
    chapters = build(units, chosen, end_time)
    quotes = quote_candidates(units, opts["quotes"])
    speakers = Counter(u["speaker"] for u in units if u["speaker"])
    notes = []
    if not timed:
        notes.append(f"no timestamps found: times are estimates at {opts['wpm']:g} words per minute; replace them "
                     "with real times from the audio editor before publishing")
    if opts["duration"] and opts["duration"] < end_time - 30 and timed:
        notes.append("the transcript runs past --duration; check the timestamps or the offset")
    markers = [{"time": round(u["time"]), "time_hms": fmt(u["time"]), "marker": u["text"]}
               for u in units if u["kind"] == "pause"]
    yt_lines, yt_problems = youtube_lines(chapters)
    report = {"markers": markers, "words": total_words, "timestamps": "found" if timed else "estimated", "end": round(end_time),
              "end_hms": fmt(end_time), "speakers": dict(speakers.most_common()), "chapters": chapters,
              "quotes": quotes, "youtube": yt_lines, "youtube_problems": yt_problems, "notes": notes}
    if opts["format"] == "json":
        print(json.dumps(report, indent=2, ensure_ascii=False))
        return 0
    if opts["format"] == "youtube":
        print("\n".join(yt_lines))
        for p in yt_problems + notes:
            print(f"# note: {p}")
        return 0
    spk = ", ".join(f"{s} ({c} turns)" for s, c in speakers.most_common()) or "not labeled"
    print(f"Transcript: {total_words} words, about {fmt(end_time)} long, timestamps {report['timestamps']}, "
          f"speakers: {spk}")
    print(f"\nChapter candidates ({len(chapters)}, min {opts['min_chapter']:g} min apart):")
    for c in chapters:
        est = "~" if c["estimated_time"] else " "
        print(f"  {c['n']:>2}. {est}{c['start_hms']:>8}  {c['minutes']:>5.1f} min  [{c['source']}] {c['title_draft']}")
        print(f"      keywords: {', '.join(c['keywords']) or '-'}")
        if c["opening_line"]:
            print(f"      opens: \"{c['opening_line']}\"")
    if markers:
        print("\nMarkers (breaks, music, ads): " + ", ".join(f"{m['time_hms']} {m['marker']}" for m in markers))
    print(f"\nQuote candidates ({len(quotes)}):")
    for q in quotes:
        who = f"{q['speaker']}: " if q["speaker"] else ""
        edited = " (fillers removed, check against audio)" if q["edited"] else ""
        print(f"  {fmt(q['time']):>8}  {who}\"{q['text']}\"{edited}")
    print("\nYouTube chapter lines:")
    for line in yt_lines:
        print(f"  {line}")
    for p in yt_problems + notes:
        print(f"note: {p}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
PythonWritingSEO+1
F@f
0
Trước4 / 4