لديك صورة جواز سفر وخدمة .NET 8 تحتاج إلى الاسم والرقم والتواريخ والمنطقة المقروءة آليًا (MRZ) في صورة بيانات. يبني هذا المقال ذلك العميل بلغة C# 12 فقط: HttpClient وSystem.Net.Http.Json وSystem.Text.Json، دون SDK. ويتناول الاستدعاء الأول، والـrecords الخاصة بالاستجابة، والحالات التي ليست أخطاء، وإعادة المحاولة التي لا تخصم مرتين أبدًا، ومعالجة الأخطاء بحسب الرمز، وعميلًا مُنمّطًا (typed client) لـASP.NET Core.
الواجهة المستخدمة هي واجهة API للتعرّف على جوازات السفر وبطاقات الهوية التي تقف خلف doc.cheap. وهي متزامنة: طلب POST واحد، وإجابة واحدة، ولا معرّف مهمة يحتاج إلى استطلاع دوري (polling).
الاستدعاء الأول، دون حساب
مفتاح الـsandbox العام هو sk_sandbox_public. لا يحتاج إلى تسجيل ولا يخصم أي رصيد. وهو محدود بـ10 طلبات في الساعة لكل عنوان IP وبعدد صغير من المحاولات المجانية مدى الحياة، وبعدها يجيب بـ403 registration_required.
جسم الطلب JSON فيه حقل إلزامي واحد هو image: بايتات الملف بترميز base64. تُقبل صيغتا JPEG وPNG؛ وكل ما عداهما يُرفض بـ415 unsupported_media_type، ويُفحص ذلك من توقيع الملف لا من اسمه.
using System.Net.Http.Headers;
using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://api.doc.cheap/v1/") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "sk_sandbox_public");
var image = Convert.ToBase64String(await File.ReadAllBytesAsync("passport.jpg"));
using var response = await http.PostAsJsonAsync("scans", new { image });
Console.WriteLine((int)response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());
مخطط الطلب صارم. أي مفتاح غير معروف في الجسم يُرفض بـ422 validation_failed، فيفشل الخطأ الإملائي في اسم أحد الخيارات بوضوح بدلًا من أن يُتجاهل بصمت.
ما الذي يعود
لكل إجابة المجموعات الثماني نفسها، وكل مفتاح موجود دائمًا: meta وdocument وholder وfields وmrz وimages وquality وauthenticity. إليك مثالًا مختصرًا بقيم مُقنَّعة:
{
"meta": {
"schema_version": "1.0",
"id": "<SCAN_ID>",
"status": "recognized",
"billed": true,
"confidence": "high",
"created_at": "<ISO_TIMESTAMP>",
"reference": null
},
"document": {
"kind": "passport",
"country": "<ISO3>",
"number": "<DOCUMENT_NUMBER>",
"expiry_date": "<YYYY-MM-DD>",
"is_expired": false
},
"holder": {
"given_names": "<GIVEN_NAMES>",
"surname": "<SURNAME>",
"birth_date": "<YYYY-MM-DD>",
"sex": "<M|F|X>",
"nationality": "<ISO3>"
},
"mrz": { "status": "passed", "reason": null, "lines": ["<MRZ_LINE_1>", "<MRZ_LINE_2>"], "text": "<MRZ_TEXT>" }
}
تكون قيمة document وholder هي null حين لا يُقرأ شيء. وقيمة mrz.status هي passed أو failed أو absent، وpassed تعني أن أرقام التحقق متطابقة.
records للاستجابة
يستخدم JSON صيغة snake_case. ويأتي .NET 8 ومعه JsonNamingPolicy.SnakeCaseLower، لذا يمكن أن تحتفظ الـrecords بأسماء C# المعتادة. والخصائص غير المعروفة تُتجاهل افتراضيًا، فلا تُعرِّف إلا ما تستخدمه.
using System.Text.Json;
public static class Api
{
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
}
public sealed record Scan(Meta Meta, DocumentInfo? Document, Holder? Holder, Mrz Mrz);
public sealed record Meta(
string Id, string Status, bool Billed, string Confidence,
string CreatedAt, string? Reference);
public sealed record DocumentInfo(
string? Kind, string? Country, string? Number,
string? IssueDate, string? ExpiryDate, bool? IsExpired, int? DaysRemaining);
public sealed record Holder(
string? GivenNames, string? Surname, string? BirthDate,
string? Sex, string? Nationality);
public sealed record Mrz(string Status, string? Reason, IReadOnlyList<string>? Lines, string? Text);
public sealed record ApiError(string Code, string Message, string? DocsUrl, string? RequestId);
public sealed record ErrorEnvelope(ApiError Error);
التواريخ تبقى هنا سلاسل نصية عن قصد. حلّلها باستخدام DateOnly.TryParse حيث تحتاج إليها، حتى لا تتسبب قيمة غريبة واحدة في وثيقة ما في رمي استثناء داخل الـdeserializer.
الصورة الضبابية ليست استثناءً
لا تعني 200 دائمًا أن الوثيقة قُرئت. تفرّع بحسب meta.status:
meta.status |
المعنى | ما العمل |
|---|---|---|
recognized |
حُدِّد نوع الوثيقة واستُخرجت منها البيانات | استخدم document وholder وmrz |
no_document_found |
لا شيء يشبه وثيقة في الإطار | اطلب صورة جديدة |
unreadable |
كانت هناك وثيقة، لكن لم يُقرأ نص صالح للاستخدام | اطلب صورة أوضح |
unsupported_document |
عُثر على وثيقة، لكن نوعها ليس من الأنواع المعروفة | أخبر المستخدم |
rejected |
فشل التعرّف من جهة الخدمة | سجّل meta.id وأعد المحاولة لاحقًا |
من يدفع: حقل billed
مفاتيح الـsandbox لا تخصم أي رصيد أبدًا. أما مع مفتاح حقيقي (live) فلا يُحتسب المسح إلا إذا كان قابلًا للاحتساب: أي حُدِّد نوع الوثيقة، وقُرئ واحد على الأقل مما يلي – منطقة MRZ تجتاز أرقام تحققها، أو خمسة حقول أو أكثر من المنطقة المطبوعة، أو رمز شريطي فُكّ ترميزه. وإلا فيُحرَّر الرصيد المحجوز. ومع مفتاح الـsandbox يظل meta.billed يبيّن هل استوفى المسح هذه القاعدة، لكن لا يُمسّ أي رصيد.
يخبرك meta.billed بما حدث. خزّنه بجانب طلب الشراء أو المستخدم لديك، ولن تضطر أبدًا إلى مطابقة الخصومات بالتخمين. ويحصل الحساب الحقيقي أيضًا على 100 عملية تعرّف مجانية كل شهر.
إعادة محاولة لا تخصم مرتين
انتهاء المهلة من جهتك لا يعني أن المسح لم يُنفَّذ من جهتنا. أرسل الترويسة Idempotency-Key (من 1 إلى 255 محرفًا) وأعد استخدامها في كل محاولة للوثيقة نفسها. مع مفتاح حقيقي، يطابق الخادم المفتاح مع بصمة الجسم معًا:
- المفتاح نفسه والجسم نفسه: تُعاد النتيجة الأولى، ولا يُخصم رصيد ثانٍ.
- المفتاح نفسه وجسم مختلف:
409 idempotency_conflict. استخدم مفتاحًا جديدًا. - الاستدعاء الأول ما زال قيد التنفيذ: ينتظر الثاني لحظات، ثم يحصل على
409 idempotency_in_progress. احتفظ بالمفتاح وأعد المحاولة. - النتيجة الأولى لم تعد مخزّنة:
409 idempotency_replay_unavailable. استخدم مفتاحًا جديدًا.
إذا فشل المحرّك بعد حجز الرصيد، يُحرَّر الحجز ويصبح المفتاح متاحًا لإعادة محاولة مشروعة. التفاصيل في دليل إعادة المحاولة بأمان باستخدام idempotency.
العميل كاملًا
هذه هي النسخة التي تستحق الاحتفاظ بها. لا تعيد المحاولة إلا مع الرموز التي تستحق ذلك، وتحترم Retry-After حتى 60 ثانية، وتحتفظ بمفتاح واحد عبر المحاولات، وتحوّل كل خطأ آخر إلى استثناء يحمل رمزه.
using System.Net.Http.Json;
public sealed class ScanFailed(int status, ApiError error)
: Exception($"{status} {error.Code}: {error.Message}")
{
public int Status { get; } = status;
public ApiError Error { get; } = error;
}
public sealed class PassportClient(HttpClient http)
{
static readonly HashSet<string> Retryable =
[
"rate_limited", "document_repeated", "internal_error", "engine_unavailable",
"service_unavailable", "maintenance", "idempotency_in_progress",
];
public async Task<Scan> ReadAsync(
byte[] imageBytes, string idempotencyKey, string? reference = null,
CancellationToken ct = default)
{
var body = new
{
image = Convert.ToBase64String(imageBytes),
reference,
options = new { return_portrait = false },
};
for (var attempt = 0; ; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "scans")
{
Content = JsonContent.Create(body),
};
request.Headers.Add("Idempotency-Key", idempotencyKey);
using var response = await http.SendAsync(request, ct);
if (response.IsSuccessStatusCode)
return (await response.Content.ReadFromJsonAsync<Scan>(Api.Json, ct))!;
var envelope = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(Api.Json, ct);
var error = envelope?.Error
?? new ApiError("unknown", response.ReasonPhrase ?? "", null, null);
if (!Retryable.Contains(error.Code) || attempt >= 3)
throw new ScanFailed((int)response.StatusCode, error);
var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1 << attempt);
await Task.Delay(wait > TimeSpan.FromSeconds(60) ? TimeSpan.FromSeconds(60) : wait, ct);
}
}
}
ينبغي أن يأتي المفتاح من نظامك أنت – معرّف رفع أو معرّف طلب شراء – لا من Guid.NewGuid() داخل الدالة. فالمفتاح المأخوذ من بياناتك يبقى كما هو بعد إعادة تشغيل العملية؛ أما الـGUID الجديد فلا.
لكل خطأ الشكل نفسه: code وmessage وdocs_url وrequest_id. تفرّع بحسب code، ولا تتفرع أبدًا بحسب message، وسجّل request_id حين تراسل الدعم. القائمة الكاملة في دليل معالجة الأخطاء. ثلاثة ستصادفها مبكرًا:
402 insufficient_credits– نفد الرصيد الحقيقي. غير قابل لإعادة المحاولة.413 payload_too_large– الجسم يتجاوز الحد. صغّر حجم الصورة.401 unauthorized– المفتاح مفقود أو غير مقبول. غير قابل لإعادة المحاولة.
عميل مُنمّط باستخدام IHttpClientFactory
لا تُنشئ HttpClient جديدًا لكل طلب. في ASP.NET Core، سجّل العميل مرة واحدة ودع الـfactory يدير الـhandlers:
builder.Services.AddHttpClient<PassportClient>(http =>
{
http.BaseAddress = new Uri("https://api.doc.cheap/v1/");
http.Timeout = TimeSpan.FromSeconds(60);
http.DefaultRequestHeaders.Authorization = new("Bearer",
builder.Configuration["DocCheap:ApiKey"]);
});
احفظ المفتاح في الإعدادات أو في مخزن للأسرار، لا في الشيفرة المصدرية أبدًا. يبدأ المفتاح الحقيقي بـsk_live_، ومفتاح الـsandbox بـsk_sandbox_. إذا أضفت resilience handler من Microsoft.Extensions.Http.Resilience، فاترك إعادة المحاولة لطبقة واحدة فقط؛ فطبقتان تضاعفان عدد المحاولات.
الاحتفاظ ببيانات أقل
لا تخزّن الصورة بنفسك بعد الاستدعاء. اقرأ ما تحتاج إليه من النتيجة وتخلّص من البايتات. العميل أعلاه يرسل أصلًا return_portrait: false، لذا لا تعود قصاصة الصورة الشخصية في الإجابة.
مع مفتاح حقيقي، تُحفظ النتيجة طوال مدة السجل في حسابك، وهي سنة واحدة افتراضيًا. ولكي لا يُحفظ شيء لاستدعاء بعينه، أرسل retain_hours: 0 داخل options: لا يُكتب أي صف ولا تُنشأ أي صورة مصغّرة. والمقابل أن إعادة محاولة ذلك الاستدعاء لا يمكن أن تسترجع النتيجة، وتعود بـidempotency_replay_unavailable. اختر مدة قصيرة أكبر من الصفر إذا أردت الأمرين معًا. يشرح دليل التعرّف على جوازات السفر كل الخيارات.
الانتقال إلى الإنتاج
استبدل sk_sandbox_public بمفتاحك sk_live_ ولا شيء آخر يتغير. المفاتيح المسجّلة محدودة بـ60 طلبًا في الدقيقة. وإذا كنت ما زلت تختار مزوّد خدمة، فإن مقارنة واجهات API للتعرّف على جوازات السفر تسرد البدائل المدفوعة مع أسعارها المنشورة.
سؤال لك: هل تحلّل منطقة MRZ بنفسك في .NET اليوم، أم تثق بخدمة تتولى ذلك؟ أخبرنا في التعليقات أدناه.