subopen 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/index.d.ts +71 -0
- package/index.js +20 -0
- package/lib/download.js +149 -0
- package/lib/http.js +78 -0
- package/lib/search.js +180 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wisecolt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# subopen
|
|
2
|
+
|
|
3
|
+
**opensubtitles.org** için unofficial API — IMDB numarasıyla altyazı arama ve indirme.
|
|
4
|
+
|
|
5
|
+
subTR paketinin kardeşi: turkcealtyazi.org yerine opensubtitles.org'u hedefler, aynı mimariyi
|
|
6
|
+
(fetchWithRetry + ZIP parse + UTF-8 dönüşümü) kullanır. Film ve dizi ayrımı otomatik yapılır.
|
|
7
|
+
|
|
8
|
+
Modern, Promise-tabanlı, ES Modules. Node 18+ native `fetch` kullanır.
|
|
9
|
+
|
|
10
|
+
## Nasıl çalışır?
|
|
11
|
+
|
|
12
|
+
Sitedeki arama formuna IMDB numarası yazınca site tahmin edilebilir bir GET adresine gider:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
https://www.opensubtitles.org/en/search/sublanguageid-{DİL}/imdbid-{IMDB}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Bu paket aynı adresin `/xml` sürümünü çeker — HTML parse yok, yapılandırılmış XML gelir.
|
|
19
|
+
İndirme için `dl.opensubtitles.org` uç noktası kullanılır (token/session gerekmez).
|
|
20
|
+
|
|
21
|
+
## Kurulum
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install subopen
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Kullanım
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { search, download } from "subopen";
|
|
31
|
+
|
|
32
|
+
// 1. IMDB ile ara (film VEYA dizi — otomatik algılanır)
|
|
33
|
+
const result = await search({ imdbId: "tt0111161", lang: "tur" });
|
|
34
|
+
|
|
35
|
+
if (result.kind === "movie") {
|
|
36
|
+
// Film (veya dizi bölümü): altyazılar hazır
|
|
37
|
+
console.log(result.movie.title); // "The Shawshank Redemption"
|
|
38
|
+
console.log(result.total); // 67
|
|
39
|
+
console.log(result.subtitles[0]);
|
|
40
|
+
// {
|
|
41
|
+
// id: "10749539",
|
|
42
|
+
// name: "The Shawshank Redemption (1994) - YIFY",
|
|
43
|
+
// language: "Turkish", iso: "tr", format: "srt", cds: 1,
|
|
44
|
+
// date: "2024-07-01", rating: 0, downloads: 160,
|
|
45
|
+
// hd: true, bad: false, uploader: "SubSceneImport",
|
|
46
|
+
// url: "https://www.opensubtitles.org/en/subtitles/10749539/...",
|
|
47
|
+
// downloadUrl: "https://dl.opensubtitles.org/en/download/subad/10749539"
|
|
48
|
+
// }
|
|
49
|
+
|
|
50
|
+
// 2. Altyazıyı indir
|
|
51
|
+
const { srt, fileName } = await download(result.subtitles[0].id);
|
|
52
|
+
fs.writeFileSync("shawshank.tr.srt", srt, "utf8");
|
|
53
|
+
} else {
|
|
54
|
+
// Dizi: sonuç bölüm listesidir; her bölümün kendi IMDB numarası vardır.
|
|
55
|
+
// İstenen bölümün IMDB'siyle search tekrar çağrılır → altyazılar.
|
|
56
|
+
const ep = result.episodes.find((e) => e.season === 1 && e.episode === 1);
|
|
57
|
+
const epResult = await search({ imdbId: ep.imdbId, lang: "tur" });
|
|
58
|
+
const { srt } = await download(epResult.subtitles[0].id);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API
|
|
63
|
+
|
|
64
|
+
### `search({ imdbId, lang, offset })` → `Promise<SearchResult>`
|
|
65
|
+
|
|
66
|
+
opensubtitles.org'da IMDB numarasıyla arar. Sonuç türü `kind` alanıyla ayrılır —
|
|
67
|
+
kullanıcının film mi dizi mi bilmesine gerek yoktur.
|
|
68
|
+
|
|
69
|
+
| Parametre | Tip | Açıklama |
|
|
70
|
+
|-----------|-----|----------|
|
|
71
|
+
| `imdbId` | `string \| number` | IMDB numarası: `"tt0111161"`, `"0111161"`, `111161` (zorunlu) |
|
|
72
|
+
| `lang` | `string` | Dil kodu: `"tur"` (varsayılan), `"eng"`, `"tur,eng"`, `"all"` |
|
|
73
|
+
| `offset` | `number` | Sayfalama başlangıcı; sayfa başına 40 sonuç (varsayılan `0`) |
|
|
74
|
+
|
|
75
|
+
**Film sonucu:** `{ kind: "movie", movie, total, offset, subtitles }`
|
|
76
|
+
|
|
77
|
+
| Alan | Açıklama |
|
|
78
|
+
|------|----------|
|
|
79
|
+
| `movie` | `{ title, year, imdbId, imdbRating }` |
|
|
80
|
+
| `total` | Toplam sonuç sayısı (tüm sayfalar) |
|
|
81
|
+
| `subtitles` | Altyazı dizisi (site sıralamasıyla) |
|
|
82
|
+
|
|
83
|
+
**Dizi sonucu:** `{ kind: "series", movie, episodes }`
|
|
84
|
+
|
|
85
|
+
| Alan | Açıklama |
|
|
86
|
+
|------|----------|
|
|
87
|
+
| `movie` | `{ title, year: null, imdbId, imdbRating: null }` — başlık bölüm adlarından çıkarılır |
|
|
88
|
+
| `episodes` | `{ season, episode, name, imdbId, count }[]` — `imdbId` ile `search()` tekrar çağrılır |
|
|
89
|
+
|
|
90
|
+
> Dizi bölümlerinin altyazıları, bölümün kendi IMDB numarasıyla sorgulanınca
|
|
91
|
+
> `kind: "movie"` şeklinde döner (bölüm sayfası tek yapıştır). Bu iki adımlı akış
|
|
92
|
+
> paketin tasarımının parçasıdır — sitenin kendisi de böyle çalışır.
|
|
93
|
+
|
|
94
|
+
### `download(subtitleId)` → `Promise<{ srt, fileName }>`
|
|
95
|
+
|
|
96
|
+
Altyazıyı indirir ve UTF-8 SRT olarak döner.
|
|
97
|
+
|
|
98
|
+
| Parametre | Tip | Açıklama |
|
|
99
|
+
|-----------|-----|----------|
|
|
100
|
+
| `subtitleId` | `string \| number` | `search()` sonucundaki `id` alanı (örn `"10749539"`) |
|
|
101
|
+
|
|
102
|
+
**Dönüş:** `{ srt: string (UTF-8), fileName: string }` — ZIP'ten SRT çıkarılır,
|
|
103
|
+
CP1254 (Türkçe Windows-1254) encoding UTF-8'e çevrilir.
|
|
104
|
+
|
|
105
|
+
## Özellikler
|
|
106
|
+
|
|
107
|
+
- ✅ IMDB ile arama (`tt` öneki, baştaki sıfırlar, sayı girdisi — hepsi kabul)
|
|
108
|
+
- ✅ Film/dizi otomatik ayrımı (`kind`) — dizi ise bölüm listesi + bölüm IMDB'leri
|
|
109
|
+
- ✅ Çoklu dil desteği (`"tur,eng"` gibi)
|
|
110
|
+
- ✅ Sayfalama (`offset`; sayfa başına 40)
|
|
111
|
+
- ✅ Altyazı indirme (dl.opensubtitles.org → ZIP → UTF-8 SRT)
|
|
112
|
+
- ✅ CP1254 → UTF-8 encoding dönüşümü
|
|
113
|
+
- ✅ Timeout + retry (ağ halletarına dayanıklı)
|
|
114
|
+
- ✅ Zero-config: API key / login gerekmez
|
|
115
|
+
- ✅ HTML scrape yok — resmî XML uç noktası (site tasarım değişikliğine dayanıklı)
|
|
116
|
+
|
|
117
|
+
## Test
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npm test
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Doğrulanmış test sonuçleri (28/28 geçti)
|
|
124
|
+
|
|
125
|
+
| Senaryo | Sonuç |
|
|
126
|
+
|---------|-------|
|
|
127
|
+
| Film: The Shawshank Redemption (tt0111161, tur) | 67 sonuç, alanlar doğru |
|
|
128
|
+
| Dizi: Breaking Bad (tt0903747, tur) | `kind: "series"`, 62 bölüm, bölüm IMDB'leri |
|
|
129
|
+
| Bölüm: S1E1 Pilot (959621, tur) | 9 altyazı |
|
|
130
|
+
| Çoklu dil: tur,eng (111161) | 215 sonuç, iki dil de var |
|
|
131
|
+
| Olmayan IMDB (tt999999999) | Boş sonuç, crash yok |
|
|
132
|
+
| Geçersiz girdi ("abc") | Anlaşılır hata |
|
|
133
|
+
| Download: 10749539 | 101K UTF-8 SRT, Türkçe karakterler düzgün |
|
|
134
|
+
|
|
135
|
+
## subTR ile farklar
|
|
136
|
+
|
|
137
|
+
| | subTR | subopen |
|
|
138
|
+
|---|-------|---------|
|
|
139
|
+
| Kaynak | turkcealtyazi.org | opensubtitles.org |
|
|
140
|
+
| Arama | İsim/yıl/IMDB (HTML scrape) | Sadece IMDB (XML uç noktası) |
|
|
141
|
+
| Dil | Sadece Türkçe | Tüm diller (`lang` parametresi) |
|
|
142
|
+
| Dizi davranışı | Sezon/bölüm altyazı alanlarında | Bölüm listesi → bölüm IMDB'siyle 2. arama |
|
|
143
|
+
| FPS bilgisi | ✅ | ❌ (OpenSubtitles XML'de yok) |
|
|
144
|
+
|
|
145
|
+
## Süreç (adım adım)
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
search({ imdbId, lang, offset })
|
|
149
|
+
│
|
|
150
|
+
├─ 1. IMDB normalize ("tt0111161" → "111161")
|
|
151
|
+
├─ 2. GET /en/search/sublanguageid-{lang}/imdbid-{imdb}[/offset-N]/xml
|
|
152
|
+
├─ 3. XML'de <search><results><subtitle> satırlarını parse et:
|
|
153
|
+
│ • IDSubtitle'lı satır → altyazı kaydı
|
|
154
|
+
│ • EpisodeName'li satır → bölüm kaydı (bölüm IMDB'si linkte)
|
|
155
|
+
└─ 4. Bölüm satırı varsa → { kind: "series", episodes }
|
|
156
|
+
yoksa → { kind: "movie", subtitles }
|
|
157
|
+
|
|
158
|
+
download(subtitleId)
|
|
159
|
+
│
|
|
160
|
+
├─ 1. GET https://dl.opensubtitles.org/en/download/subad/{id} → ZIP
|
|
161
|
+
├─ 2. ZIP'ten SRT çıkar (tüm entry'ler taranır, .nfo atlanır)
|
|
162
|
+
└─ 3. UTF-8 dene, bozuksa CP1254 → UTF-8 çevir
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Notlar
|
|
166
|
+
|
|
167
|
+
- Site düz bot isteklerine 401 döndürdüğü için gerçekçi bir Chrome User-Agent gönderilir.
|
|
168
|
+
- `subtitleserve` indirme uç noktası bot koruması (Anubis) arkasında olduğundan
|
|
169
|
+
`dl.opensubtitles.org` kullanılır — anonim indirme çalışır.
|
|
170
|
+
- OpenSubtitles'ta anonim kullanıcılara günlük indirme limiti uygulanabilir.
|
|
171
|
+
|
|
172
|
+
## Uyarı: unofficial ve best-effort
|
|
173
|
+
|
|
174
|
+
Bu paket opensubtitles.org'un **resmî API'sini kullanmaz**; sitenin halka açık
|
|
175
|
+
XML arama sayfasını ve `dl.opensubtitles.org` indirme uç noktasını okur. Site
|
|
176
|
+
bu uç noktalardan birini değiştirirse paket bozulabilir — böyle bir durumda
|
|
177
|
+
issue açın. Sürümün 0.x'te tutulmasının sebebi tam olarak bu dış bağımlılıktır.
|
|
178
|
+
|
|
179
|
+
## Lisans
|
|
180
|
+
|
|
181
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// subopen — tip tanımları
|
|
2
|
+
// Kaynak: lib/search.js ve lib/download.js içindeki dokümante edilmiş sözleşme.
|
|
3
|
+
|
|
4
|
+
/** opensubtitles.org arama seçenekleri. */
|
|
5
|
+
export interface SearchOptions {
|
|
6
|
+
/** IMDB numarası: "tt0111161", "0111161" veya 111161 (zorunlu). */
|
|
7
|
+
imdbId: string | number;
|
|
8
|
+
/** Dil kodu: "tur" (varsayılan), "eng", "tur,eng", "all". */
|
|
9
|
+
lang?: string;
|
|
10
|
+
/** Sayfalama başlangıcı; sayfa başına 40 sonuç (varsayılan 0). */
|
|
11
|
+
offset?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Film/dizi künye bilgisi. Dizi aramalarında year ve imdbRating null'dur. */
|
|
15
|
+
export interface MovieInfo {
|
|
16
|
+
title: string | null;
|
|
17
|
+
year: string | null;
|
|
18
|
+
imdbId: string;
|
|
19
|
+
imdbRating: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Tek bir altyazı kaydı (film veya dizi bölümü sayfası). */
|
|
23
|
+
export interface SubtitleItem {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
language: string;
|
|
27
|
+
iso: string;
|
|
28
|
+
format: string;
|
|
29
|
+
cds: number | null;
|
|
30
|
+
date: string;
|
|
31
|
+
rating: number;
|
|
32
|
+
downloads: number;
|
|
33
|
+
hd: boolean;
|
|
34
|
+
bad: boolean;
|
|
35
|
+
uploader: string;
|
|
36
|
+
url: string | null;
|
|
37
|
+
downloadUrl: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Dizi aramasında tek bir bölüm; imdbId ile search() tekrar çağrılır. */
|
|
41
|
+
export interface EpisodeItem {
|
|
42
|
+
season: number | null;
|
|
43
|
+
episode: number | null;
|
|
44
|
+
name: string;
|
|
45
|
+
fullName: string;
|
|
46
|
+
imdbId: string;
|
|
47
|
+
count: number | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MovieResult {
|
|
51
|
+
kind: "movie";
|
|
52
|
+
movie: MovieInfo;
|
|
53
|
+
/** Tüm sayfaların toplam sonucu. */
|
|
54
|
+
total: number;
|
|
55
|
+
offset: number;
|
|
56
|
+
subtitles: SubtitleItem[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SeriesResult {
|
|
60
|
+
kind: "series";
|
|
61
|
+
movie: MovieInfo;
|
|
62
|
+
episodes: EpisodeItem[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SearchResult = MovieResult | SeriesResult;
|
|
66
|
+
|
|
67
|
+
export declare function search(options: SearchOptions): Promise<SearchResult>;
|
|
68
|
+
|
|
69
|
+
export declare function download(
|
|
70
|
+
subtitleId: string | number
|
|
71
|
+
): Promise<{ srt: string; fileName: string }>;
|
package/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// subopen — opensubtitles.org unofficial API
|
|
2
|
+
//
|
|
3
|
+
// Export edilen fonksiyonlar:
|
|
4
|
+
// search({ imdbId, lang, offset }) → Promise<SearchResult>
|
|
5
|
+
// SearchResult: { kind: "movie", movie, total, subtitles }
|
|
6
|
+
// | { kind: "series", movie, episodes }
|
|
7
|
+
// download(subtitleId) → Promise<{ srt, fileName }>
|
|
8
|
+
//
|
|
9
|
+
// Örnek kullanım:
|
|
10
|
+
// import { search, download } from "subopen";
|
|
11
|
+
// const result = await search({ imdbId: "tt0111161", lang: "tur" });
|
|
12
|
+
// if (result.kind === "movie") {
|
|
13
|
+
// const { srt } = await download(result.subtitles[0].id);
|
|
14
|
+
// } else {
|
|
15
|
+
// // Dizi: bölüm listesi gelir, bölümün IMDB'siyle tekrar aranır
|
|
16
|
+
// const ep = await search({ imdbId: result.episodes[0].imdbId, lang: "tur" });
|
|
17
|
+
// }
|
|
18
|
+
|
|
19
|
+
export { search } from "./lib/search.js";
|
|
20
|
+
export { download } from "./lib/download.js";
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// opensubtitles.org altyazı indirme.
|
|
2
|
+
//
|
|
3
|
+
// Akış:
|
|
4
|
+
// 1. GET https://dl.opensubtitles.org/en/download/subad/{id} → ZIP dosyası
|
|
5
|
+
// (subtitleserve uç noktası bot koruması arkasında olduğu için
|
|
6
|
+
// dl.opensubtitles.org kullanılır; token/session gerekmez)
|
|
7
|
+
// 2. ZIP'ten SRT çıkar
|
|
8
|
+
// 3. Encoding'i UTF-8'e çevir (OpenSubtitles SRT'leri çoğunlukla CP1254'tür)
|
|
9
|
+
//
|
|
10
|
+
// ZIP parse mantığı subTR paketinden (lib/download.js) uyarlanmıştır;
|
|
11
|
+
// giriş sırası ne olursa olsun tüm entry'ler taranır (.srt + .nfo karışık
|
|
12
|
+
// sıralı ZIP'lerde bile).
|
|
13
|
+
|
|
14
|
+
import zlib from "zlib";
|
|
15
|
+
import { fetchWithRetry, DOWNLOAD_HOST } from "./http.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bir altyazıyı indir.
|
|
19
|
+
* @param {string|number} subtitleId — IDSubtitle değeri (örn "10749539"; search() sonucunun id alanı)
|
|
20
|
+
* @returns {Promise<{ srt: string, fileName: string }>} UTF-8 SRT içeriği
|
|
21
|
+
*/
|
|
22
|
+
export async function download(subtitleId) {
|
|
23
|
+
const id = String(subtitleId ?? "").trim();
|
|
24
|
+
if (!/^\d+$/.test(id)) {
|
|
25
|
+
throw new Error(`Geçersiz altyazı ID: ${JSON.stringify(subtitleId)} (örn. "10749539")`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const resp = await fetchWithRetry(`${DOWNLOAD_HOST}/en/download/subad/${id}`);
|
|
29
|
+
if (!resp.ok) {
|
|
30
|
+
throw new Error(`İndirme başarısız (HTTP ${resp.status})`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Bot koruması / hata sayfası ZIP değil HTML döndürür
|
|
34
|
+
const contentType = resp.headers.get("content-type") || "";
|
|
35
|
+
if (contentType.includes("text/html")) {
|
|
36
|
+
throw new Error("İndirme engellendi (HTML döndü). ID doğru mu kontrol edin.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const zipBuffer = Buffer.from(await resp.arrayBuffer());
|
|
40
|
+
const extracted = extractSrtFromZip(zipBuffer);
|
|
41
|
+
if (!extracted) {
|
|
42
|
+
throw new Error("ZIP içinde SRT dosyası bulunamadı");
|
|
43
|
+
}
|
|
44
|
+
return extracted;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ZIP buffer'ından SRT içeriğini çıkarır.
|
|
49
|
+
* OpenSubtitles ZIP'leri genelde .srt + .nfo içerir; .srt hangi sırada
|
|
50
|
+
* olursa olsun bulunana kadar tüm entry'ler taranır.
|
|
51
|
+
* @param {Buffer} zipBuffer
|
|
52
|
+
* @returns {{ srt: string, fileName: string } | null}
|
|
53
|
+
*/
|
|
54
|
+
function extractSrtFromZip(zipBuffer) {
|
|
55
|
+
let offset = 0;
|
|
56
|
+
while (offset < zipBuffer.length - 4) {
|
|
57
|
+
// Local file header signature: PK\x03\x04
|
|
58
|
+
if (zipBuffer[offset] === 0x50 && zipBuffer[offset + 1] === 0x4b &&
|
|
59
|
+
zipBuffer[offset + 2] === 0x03 && zipBuffer[offset + 3] === 0x04) {
|
|
60
|
+
const entry = parseZipEntry(zipBuffer, offset);
|
|
61
|
+
if (entry.srt) return entry;
|
|
62
|
+
offset = entry.nextOffset;
|
|
63
|
+
} else {
|
|
64
|
+
offset++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tek bir ZIP local file header'ını parse eder.
|
|
72
|
+
* Sadece deflate (method 8) ve store (method 0) destekler.
|
|
73
|
+
*/
|
|
74
|
+
function parseZipEntry(buf, headerOffset) {
|
|
75
|
+
// Local file header yapısı (ZIP spec):
|
|
76
|
+
// 0 signature (4) 18 compressed size (4)
|
|
77
|
+
// 4 version needed (2) 22 uncompressed size (4)
|
|
78
|
+
// 6 flags (2) 26 filename length (2)
|
|
79
|
+
// 8 compression method (2) 28 extra field length (2)
|
|
80
|
+
// 10 mod time (2) 30 filename
|
|
81
|
+
// 12 mod date (2) ... extra field
|
|
82
|
+
// 14 CRC-32 (4) ... file data
|
|
83
|
+
|
|
84
|
+
const compressionMethod = buf.readUInt16LE(headerOffset + 8);
|
|
85
|
+
const compressedSize = buf.readUInt32LE(headerOffset + 18);
|
|
86
|
+
const filenameLen = buf.readUInt16LE(headerOffset + 26);
|
|
87
|
+
const extraLen = buf.readUInt16LE(headerOffset + 28);
|
|
88
|
+
|
|
89
|
+
const filenameStart = headerOffset + 30;
|
|
90
|
+
const fileName = buf.slice(filenameStart, filenameStart + filenameLen).toString("latin1");
|
|
91
|
+
|
|
92
|
+
const dataStart = filenameStart + filenameLen + extraLen;
|
|
93
|
+
const compressedData = buf.slice(dataStart, dataStart + compressedSize);
|
|
94
|
+
|
|
95
|
+
// Sadece .srt dosyalarını işle; diğer entry'leri (.nfo vb.) atla
|
|
96
|
+
if (!fileName.toLowerCase().endsWith(".srt")) {
|
|
97
|
+
return { nextOffset: dataStart + compressedSize, srt: null, fileName: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let fileBuffer;
|
|
101
|
+
if (compressionMethod === 0) {
|
|
102
|
+
fileBuffer = compressedData; // Store (sıkıştırmasız)
|
|
103
|
+
} else if (compressionMethod === 8) {
|
|
104
|
+
fileBuffer = inflateRaw(compressedData); // Deflate
|
|
105
|
+
} else {
|
|
106
|
+
return { nextOffset: dataStart + compressedSize, srt: null, fileName: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!fileBuffer) {
|
|
110
|
+
return { nextOffset: dataStart + compressedSize, srt: null, fileName: null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const srt = decodeSubtitle(fileBuffer);
|
|
114
|
+
return { srt, fileName, nextOffset: dataStart + compressedSize };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Raw deflate decompression (zlib). */
|
|
118
|
+
function inflateRaw(data) {
|
|
119
|
+
try {
|
|
120
|
+
return zlib.inflateRawSync(data);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Subtitle buffer'ını UTF-8 string'e çevirir.
|
|
128
|
+
* Önce UTF-8 dener, bozuk karakter varsa Windows-1254 (Türkçe CP1254) kullanır.
|
|
129
|
+
*/
|
|
130
|
+
function decodeSubtitle(buf) {
|
|
131
|
+
// UTF-8 BOM varsa direkt UTF-8
|
|
132
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
133
|
+
return buf.slice(3).toString("utf8");
|
|
134
|
+
}
|
|
135
|
+
// UTF-8 decode kontrolü — U+FFFD varsa Windows-1254 dene
|
|
136
|
+
const text = buf.toString("utf8");
|
|
137
|
+
if (!text.includes("\uFFFD")) return text;
|
|
138
|
+
|
|
139
|
+
// Windows-1254 / ISO-8859-9 decode
|
|
140
|
+
return buf.toString("binary").replace(/[\u00C0-\u00FF]/g, (ch) => {
|
|
141
|
+
const code = ch.charCodeAt(0);
|
|
142
|
+
const map = {
|
|
143
|
+
0xD0: "\u011E", 0xDD: "\u0130", 0xDE: "\u015E", 0xFE: "\u015F",
|
|
144
|
+
0xFD: "\u0131", 0xF0: "\u011F", 0xC7: "\u00C7", 0xE7: "\u00E7",
|
|
145
|
+
0xD6: "\u00D6", 0xF6: "\u00F6", 0xDC: "\u00DC", 0xFC: "\u00FC",
|
|
146
|
+
};
|
|
147
|
+
return map[code] || ch;
|
|
148
|
+
});
|
|
149
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// HTTP yardımcı fonksiyonları — opensubtitles.org ile güvenli iletişim.
|
|
2
|
+
//
|
|
3
|
+
// Tasarım: subTR paketinin (lib/http.js) fetchWithRetry pattern'ini taklip
|
|
4
|
+
// eder. Node 18+ native fetch kullanır. Timeout (AbortController) + retry +
|
|
5
|
+
// User-Agent header.
|
|
6
|
+
//
|
|
7
|
+
// Önemli: OpenSubtitles, tarayıcı imzası olmayan isteklere 401 döndürür;
|
|
8
|
+
// gerçekçi bir Chrome User-Agent'ı zorunludur. XML arama ve indirme
|
|
9
|
+
// uç noktaları farklı host'lardadır:
|
|
10
|
+
// Arama : https://www.opensubtitles.org/en/search/.../xml
|
|
11
|
+
// İndirme : https://dl.opensubtitles.org/en/download/subad/{id}
|
|
12
|
+
|
|
13
|
+
const BASE_URL = "https://www.opensubtitles.org";
|
|
14
|
+
const DOWNLOAD_HOST = "https://dl.opensubtitles.org";
|
|
15
|
+
const USER_AGENT =
|
|
16
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
|
17
|
+
|
|
18
|
+
const RETRYABLE_ERRORS = new Set([
|
|
19
|
+
"AbortError",
|
|
20
|
+
"TypeError",
|
|
21
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
22
|
+
"ECONNRESET",
|
|
23
|
+
"ECONNREFUSED",
|
|
24
|
+
"ENOTFOUND",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verilen URL'i timeout ve retry ile fetch eder.
|
|
29
|
+
* @param {string} url — Tam URL (https://...)
|
|
30
|
+
* @param {object} [options] — fetch options (method, headers, body, ...)
|
|
31
|
+
* @param {number} [timeoutMs=20000] — İstek timeout (ms)
|
|
32
|
+
* @param {number} [retries=3] — Ağ hatasında yeniden deneme sayısı
|
|
33
|
+
* @returns {Promise<Response>}
|
|
34
|
+
*/
|
|
35
|
+
export async function fetchWithRetry(url, options = {}, timeoutMs = 20000, retries = 3) {
|
|
36
|
+
const headers = {
|
|
37
|
+
"User-Agent": USER_AGENT,
|
|
38
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
39
|
+
...(options.headers || {}),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let lastErr = null;
|
|
43
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
46
|
+
try {
|
|
47
|
+
const resp = await fetch(url, { ...options, headers, signal: controller.signal, redirect: "follow" });
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
return resp;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
lastErr = err;
|
|
53
|
+
const errType = err?.name || err?.cause?.code || "Unknown";
|
|
54
|
+
const isNetwork =
|
|
55
|
+
RETRYABLE_ERRORS.has(errType) || (err?.message || "").includes("fetch failed");
|
|
56
|
+
if (!isNetwork || attempt === retries) throw err;
|
|
57
|
+
const delay = 1000 * (attempt + 1);
|
|
58
|
+
console.warn(`⚠️ subopen ağ hatası (${errType}), ${delay}ms sonra yeniden denenecek (${attempt + 1}/${retries})`);
|
|
59
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw lastErr;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* XML arama yanıtı çeker (text olarak).
|
|
67
|
+
* @param {string} path — "/en/search/..." gibi path
|
|
68
|
+
* @returns {Promise<string>} XML içeriği
|
|
69
|
+
*/
|
|
70
|
+
export async function fetchXml(path) {
|
|
71
|
+
const resp = await fetchWithRetry(BASE_URL + path);
|
|
72
|
+
if (!resp.ok) {
|
|
73
|
+
throw new Error(`opensubtitles.org isteği başarısız (HTTP ${resp.status}): ${path}`);
|
|
74
|
+
}
|
|
75
|
+
return await resp.text();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { BASE_URL, DOWNLOAD_HOST, USER_AGENT };
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// opensubtitles.org arama — IMDB numarasıyla XML uç noktası üzerinden.
|
|
2
|
+
//
|
|
3
|
+
// Akış:
|
|
4
|
+
// 1. search({ imdbId, lang, offset }) → /en/search/sublanguageid-{lang}/imdbid-{imdb}/xml
|
|
5
|
+
// (Form doldurmaya gerek yok; sonuç sayfasının URL'si tahmin edilebilir.
|
|
6
|
+
// Aynı adresin /xml sürümü yapılandırılmış veri döndürür — HTML parse yok.)
|
|
7
|
+
// 2. <search> bölümündeki <subtitle> satırlarını parse et:
|
|
8
|
+
// • IDSubtitle içeren satır → altyazı kaydı (film VEYA dizi bölümü sayfası)
|
|
9
|
+
// • EpisodeName içeren satır → dizi aramasında bölüm listesi satırı;
|
|
10
|
+
// her bölümün kendi IMDB numarası EpisodeName bağlantısındadır
|
|
11
|
+
// (imdbid-959621 gibi) → o numarayla tekrar search() yapılır
|
|
12
|
+
// 3. Film mi dizi mi ayrımı bununla otomatik çözülür:
|
|
13
|
+
// kind: "movie" → subtitles hazır
|
|
14
|
+
// kind: "series" → episodes listesi; episodes[i].imdbId ile alt seviyeye in
|
|
15
|
+
|
|
16
|
+
import { XMLParser } from "fast-xml-parser";
|
|
17
|
+
import { fetchXml, BASE_URL, DOWNLOAD_HOST } from "./http.js";
|
|
18
|
+
|
|
19
|
+
const parser = new XMLParser({
|
|
20
|
+
ignoreAttributes: false,
|
|
21
|
+
attributeNamePrefix: "@_",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Sayfa başına sonuç sayısı (site kuralı). */
|
|
25
|
+
export const PAGE_SIZE = 40;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* opensubtitles.org'da IMDB numarasıyla altyazı ara.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} params
|
|
31
|
+
* @param {string|number} params.imdbId — IMDB numarası: "tt0111161", "0111161", 111161...
|
|
32
|
+
* @param {string} [params.lang="tur"] — Dil kodu: "tur", "eng", "tur,eng", "all"
|
|
33
|
+
* @param {number} [params.offset=0] — Sayfalama başlangıcı (sayfa başına 40)
|
|
34
|
+
* @returns {Promise<SearchResult>}
|
|
35
|
+
* Film ise : { kind: "movie", movie, total, offset, subtitles }
|
|
36
|
+
* Dizi ise : { kind: "series", movie, episodes }
|
|
37
|
+
*/
|
|
38
|
+
export async function search({ imdbId, lang = "tur", offset = 0 } = {}) {
|
|
39
|
+
const imdb = normalizeImdbId(imdbId);
|
|
40
|
+
|
|
41
|
+
const path =
|
|
42
|
+
`/en/search/sublanguageid-${lang}/imdbid-${imdb}` +
|
|
43
|
+
(offset > 0 ? `/offset-${offset}` : "") +
|
|
44
|
+
"/xml";
|
|
45
|
+
const xml = await fetchXml(path);
|
|
46
|
+
return parseSearchXml(xml, imdb, offset);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* "tt0111161" | "0111161" | 111161 → "111161". Geçersiz girdide hata fırlatır.
|
|
51
|
+
* @param {string|number} value
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeImdbId(value) {
|
|
55
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
56
|
+
const digits = raw.replace(/^tt/, "");
|
|
57
|
+
if (!/^\d+$/.test(digits)) {
|
|
58
|
+
throw new Error(`Geçersiz IMDB numarası: ${JSON.stringify(value)} (örn. "tt0111161" veya "0111161")`);
|
|
59
|
+
}
|
|
60
|
+
return digits.replace(/^0+(?=\d)/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* XML arama yanıtını parse eder; film/dizi ayrımını yapar.
|
|
65
|
+
* @param {string} xml
|
|
66
|
+
* @param {string} imdb
|
|
67
|
+
* @param {number} offset
|
|
68
|
+
* @returns {SearchResult}
|
|
69
|
+
*/
|
|
70
|
+
function parseSearchXml(xml, imdb, offset) {
|
|
71
|
+
const doc = parser.parse(xml);
|
|
72
|
+
const searchEl = doc?.opensubtitles?.search;
|
|
73
|
+
if (!searchEl) {
|
|
74
|
+
return { kind: "movie", movie: { title: null, year: null, imdbId: imdb }, total: 0, offset, subtitles: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resultsEl = searchEl.results;
|
|
78
|
+
const rows = arrayify(resultsEl?.subtitle);
|
|
79
|
+
|
|
80
|
+
const subtitles = [];
|
|
81
|
+
const episodes = [];
|
|
82
|
+
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const idEl = row.IDSubtitle;
|
|
85
|
+
const id = textOf(idEl);
|
|
86
|
+
|
|
87
|
+
if (id) {
|
|
88
|
+
// Altyazı satırı (film sayfası veya dizi bölümü sayfası)
|
|
89
|
+
subtitles.push({
|
|
90
|
+
id,
|
|
91
|
+
name: textOf(row.MovieReleaseName) || textOf(row.MovieName) || "",
|
|
92
|
+
language: textOf(row.LanguageName) || "",
|
|
93
|
+
iso: textOf(row.ISO639) || "",
|
|
94
|
+
format: textOf(row.SubFormat) || "",
|
|
95
|
+
cds: numOr(textOf(row.SubSumCD), null),
|
|
96
|
+
date: textOf(row.SubAddDate) || "",
|
|
97
|
+
rating: numOr(textOf(row.SubRating), 0),
|
|
98
|
+
downloads: numOr(textOf(row.SubDownloadsCnt), 0),
|
|
99
|
+
hd: textOf(row.SubHD) === "1",
|
|
100
|
+
bad: textOf(row.SubBad) === "1",
|
|
101
|
+
uploader: textOf(row.UserNickName) || "",
|
|
102
|
+
url: idEl?.["@_Link"] ? BASE_URL + idEl["@_Link"] : null,
|
|
103
|
+
downloadUrl:
|
|
104
|
+
idEl?.["@_LinkDownload"] || `${DOWNLOAD_HOST}/en/download/subad/${id}`,
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Dizi araması: satır bir bölüm. Reklam/boş satırları EpisodeName olmayanı
|
|
110
|
+
// atarak eler; bölümün kendi IMDB numarası EpisodeName bağlantısındadır.
|
|
111
|
+
const epNameEl = row.EpisodeName;
|
|
112
|
+
if (epNameEl) {
|
|
113
|
+
const link = epNameEl?.["@_Link"] || "";
|
|
114
|
+
const m = String(link).match(/imdbid-(\d+)/);
|
|
115
|
+
if (m) {
|
|
116
|
+
episodes.push({
|
|
117
|
+
season: numOr(textOf(row.SeriesSeason), null),
|
|
118
|
+
episode: numOr(textOf(row.SeriesEpisode), null),
|
|
119
|
+
name: textOf(epNameEl) || "",
|
|
120
|
+
fullName: textOf(row.MovieName) || "",
|
|
121
|
+
imdbId: m[1],
|
|
122
|
+
count: numOr(textOf(row.SeriesSubtitles), null),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Sıralama site sırası korunur (ilgililik/varsayılan sıralama).
|
|
129
|
+
const total = numOr(resultsEl?.["@_itemsfound"], subtitles.length + episodes.length);
|
|
130
|
+
|
|
131
|
+
if (episodes.length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
kind: "series",
|
|
134
|
+
movie: buildMovieInfo(searchEl.Movie, imdb, episodes),
|
|
135
|
+
episodes,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
kind: "movie",
|
|
140
|
+
movie: buildMovieInfo(searchEl.Movie, imdb, []),
|
|
141
|
+
total,
|
|
142
|
+
offset,
|
|
143
|
+
subtitles,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* <Movie> öğesinden film bilgisi üretir. Dizi aramalarında <Movie> bulunmaz;
|
|
149
|
+
* başlık '"Breaking Bad" Pilot' biçimindeki bölüm tam adından çıkarılır.
|
|
150
|
+
*/
|
|
151
|
+
function buildMovieInfo(movieEl, imdb, episodes) {
|
|
152
|
+
let title = textOf(movieEl?.MovieName) || null;
|
|
153
|
+
const year = textOf(movieEl?.MovieYear) || null;
|
|
154
|
+
const imdbRating = textOf(movieEl?.MovieImdbRating) || null;
|
|
155
|
+
|
|
156
|
+
if (!title && episodes.length > 0) {
|
|
157
|
+
const m = (episodes[0].fullName || "").match(/^"([^"]+)"/);
|
|
158
|
+
if (m) title = m[1];
|
|
159
|
+
}
|
|
160
|
+
return { title, year, imdbId: imdb, imdbRating };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** fast-xml-parser düğümünden metin çıkarır ("10749539" | 10749539 | {#text} | undefined). */
|
|
164
|
+
function textOf(node) {
|
|
165
|
+
if (node == null) return "";
|
|
166
|
+
if (typeof node === "object") return String(node["#text"] ?? "");
|
|
167
|
+
return String(node);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Tek düğümü diziye sarar (fast-xml-parser tek kayıtta object döner). */
|
|
171
|
+
function arrayify(node) {
|
|
172
|
+
if (node == null) return [];
|
|
173
|
+
return Array.isArray(node) ? node : [node];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Sayıya çevirir; mümkün değilse fallback döner. */
|
|
177
|
+
function numOr(value, fallback) {
|
|
178
|
+
const n = Number(String(value ?? "").trim());
|
|
179
|
+
return Number.isFinite(n) ? n : fallback;
|
|
180
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "subopen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Unofficial API for opensubtitles.org — IMDB ile altyazı arama ve indirme (film + dizi)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"index.d.ts",
|
|
13
|
+
"lib/"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node test/test.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"subtitle",
|
|
20
|
+
"altyazi",
|
|
21
|
+
"opensubtitles",
|
|
22
|
+
"imdb",
|
|
23
|
+
"turkish",
|
|
24
|
+
"api",
|
|
25
|
+
"scraper"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git://github.com/szbk/subOpen.git"
|
|
30
|
+
},
|
|
31
|
+
"author": "wisecolt",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"fast-xml-parser": "^4.5.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"types": "index.d.ts"
|
|
40
|
+
}
|